Help us shape the future of Go! Take the 2026 Go Developer Survey

dominaite

package module
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 20 Imported by: 0

README

dominaite-go

Server-side Go client for the Dominaite merchant API. One call from your backend opens a hosted checkout session; a two-line script tag renders the payment widget on your page; a signed webhook tells you when the money actually arrived. Card details go straight from your customer's browser into the payment widget - they never touch your server, which keeps your PCI scope minimal (SAQ A).

Go 1.21 or newer. Zero dependencies: crypto/hmac, crypto/sha256, net/http, encoding/json, all standard library.

Install

go get github.com/dominaite/merchant-sdk-go
import dominaite "github.com/dominaite/merchant-sdk-go"

To work on the SDK itself:

go vet ./...
go test ./...      # includes the offline signing and webhook vectors

Credentials

You get two values from the Dominaite dashboard, Website integration tab, when you generate an API key (shown once - store them like passwords):

  • dmk_... - your API key id. Identifies you; not secret by itself.
  • dms_... - your API secret. Server-side only: environment variable or a config file outside the web root. Never in a browser, never in git, never in logs.

Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock on NTP - signatures older than 5 minutes are rejected with TIMESTAMP_OUT_OF_RANGE.

If the key has an IP allowlist, calls from anywhere else fail with IP_NOT_ALLOWED. The allowlist is managed on the same dashboard tab.

A webhook endpoint has a third secret, separate from the two above: whsec_..., shown once when you create the endpoint on the Webhooks tab or with CreateWebhookEndpoint. It signs deliveries to you rather than requests from you, so it is never sent anywhere - see Webhooks.

What we switch on for your account

Every key with the payments scope (a payments key) can create sessions, save and revoke cards, read statuses, refund, and manage plans and agreements. The features below need a step from us first, so ask us: contact Dominaite support. The dashboard has no switch for the first four.

  • Saved-card charges. Without the switch, ChargePaymentMethod answers 503 PAYMENT_METHOD_CHARGES_DISABLED.
  • Subscription sessions. Without the switch, CreateCheckoutSession with AgreementID answers success: false with SUBSCRIPTIONS_NOT_ENABLED.
  • Scheduled charges. Without the switch, the platform runs no scheduled charge and no retry, even when the subscription session works.
  • Verify-only sessions. Without the switch, CreateCheckoutSession with VerifyOnly answers 403 VERIFY_ONLY_NOT_ENABLED.
  • Card fields. We enable card fields per merchant on request.

ListTransactions and the webhook endpoint routes need a reporting key: a second key next to your payments key. It holds the transactions:read or the webhooks:manage scope. A payments key always gets HTTP 403 INSUFFICIENT_SCOPE on these routes. The reporting key is for your whole account. It cannot be bound to a storefront. If your dashboard has no reporting key section, ask us for the key.

Quickstart (zero to a paid order)

Everything below is copy-paste. It assumes an empty directory and nothing installed.

A complete integration is three moving parts, and you want all three:

  1. Create a session from your backend, and render the widget with what it returns.
  2. Receive a webhook when the payment resolves. This is how you learn you were paid.
  3. Reconcile on a schedule, because no webhook system delivers everything forever.

Steps 1 and 2 are below. Step 3 is not optional; see Reconciliation is still mandatory.

mkdir my-checkout && cd my-checkout
go mod init example.com/my-checkout
go get github.com/dominaite/merchant-sdk-go

Set your credentials and the environment you are pointing at:

export DOMINAITE_KEY_ID=dmk_...      # Website integration tab
export DOMINAITE_SECRET=dms_...      # shown once when you generated the key
export DOMINAITE_WEBHOOK_SECRET=whsec_...  # shown once when you created the endpoint
# Required. The sandbox, or https://api.dominaite.com/payments for production:
export DOMINAITE_BASE_URL=https://api.staging.dominaite.com/payments

Ping before your first mint. It is one signed GET that creates nothing, so anything that fails here is your credentials, your signing or your clock:

client, err := dominaite.New(
	os.Getenv("DOMINAITE_KEY_ID"),
	os.Getenv("DOMINAITE_SECRET"),
	dominaite.WithBaseURL(os.Getenv("DOMINAITE_BASE_URL")), // required
)
if err != nil {
	log.Fatal(err)
}

ping, err := client.Ping(context.Background())
if err != nil {
	log.Fatal(err)
}
log.Printf("merchant %s, clock skew %ds", ping.MerchantID, ping.ClockSkewSeconds)

Watch ClockSkewSeconds: the gateway rejects requests once it passes 300, so a number that keeps growing is your cue to fix NTP before payments start failing.

Step 1: create a session

main.go:

package main

import (
	"context"
	"errors"
	"fmt"
	"os"

	dominaite "github.com/dominaite/merchant-sdk-go"
)

func main() {
	client, err := dominaite.New(
		os.Getenv("DOMINAITE_KEY_ID"),
		os.Getenv("DOMINAITE_SECRET"),
		dominaite.WithBaseURL(os.Getenv("DOMINAITE_BASE_URL")), // required
	)
	if err != nil {
		panic(err)
	}

	// Required. Derived from the order, never random: a reload or a retry for the
	// same order and amount replays the session already open instead of opening
	// a second payment. "checkout-order-1042-2500-EUR".
	key, err := dominaite.OrderIdempotencyKey("checkout", "order-1042", 2500, "EUR")
	if err != nil {
		panic(err)
	}

	session, err := client.CreateCheckoutSession(context.Background(), dominaite.CreateCheckoutSessionParams{
		Amount:         2500,          // minor units: 2500 = 25.00 EUR
		Currency:       "EUR",
		OrderReference: "order-1042",  // your own order id, shows up in your dashboard
		IdempotencyKey: key,
		Customer: &dominaite.Customer{
			// Pass everything you already know - prefilled fields are hidden from the
			// payer, so the checkout form stays short.
			FirstName: "Ana",
			LastName:  "Kirova",
			Email:     "ana@example.com",
			// Billing address too, when your checkout already has it: Address,
			// City, PostalCode and State (US, Canada and Australia only).
		},
		Language: "bg",   // widget UI language
		Theme:    "dark",
	})
	if err != nil {
		var refusal *dominaite.RefusalError
		var apiErr *dominaite.APIError
		var transport *dominaite.TransportError
		switch {
		case errors.As(err, &refusal):
			// Machine-readable: refusal.ErrorCode - codes listed below.
			fmt.Println("Payment unavailable:", refusal.ErrorCode)
		case errors.As(err, &apiErr) && apiErr.ErrorCode == dominaite.ErrorCodeStorefrontNotWhitelisted:
			// HTTP 409: this site's domain is not whitelisted with the payment
			// provider yet. Not retryable until it is - see Storefront errors.
			fmt.Println("Checkout is not enabled for this site yet")
		case errors.As(err, &transport):
			// Network blip - safe to retry with the same idempotency key.
			fmt.Println("Payment temporarily unavailable")
		default:
			panic(err)
		}
		return
	}

	// Store session.TransactionID against your order, then hand CashierKey +
	// CashierToken to the page that renders the widget.
	fmt.Printf("%+v\n", session)
}
go run .

A successful run prints TransactionID, OrderID, CashierKey, CashierToken, Amount, Currency, ExpiresAt. Render the widget with the two cashier values:

<div id="checkout"></div>
<script src="https://bp-checkout.dominaite.com/v2/launcher"
        data-cashier-key="CASHIER_KEY_FROM_SESSION"
        data-cashier-token="CASHIER_TOKEN_FROM_SESSION"></script>

CashierKey and CashierToken are per-payment session values, not credentials - but HTML-escape them when you template them into the page (html/template does it for you).

There is a runnable version of the above in examples/create-session/main.go in this repo - it mints a session and reads the status back, using the same environment variables:

go run ./examples/create-session
Step 2: receive the webhook

The payer finishes on the widget, not on your site, so the session call cannot tell you the order was paid. A webhook does. Register an endpoint on the dashboard's Webhooks tab, subscribe to payment.succeeded, and handle it:

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	// The RAW bytes, before any decoding. The signature covers exactly these,
	// so decoding and re-encoding first will break verification.
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	event, err := dominaite.VerifyWebhook(
		body,
		r.Header.Get(dominaite.WebhookSignatureHeader),
		os.Getenv("DOMINAITE_WEBHOOK_SECRET"),
	)
	if err != nil {
		// Log the reason for yourself; never tell the caller why it failed.
		log.Printf("rejected webhook: %v", err)
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Delivery is at-least-once, so the same event.ID will sometimes arrive
	// twice. Dedupe on it, in your database, before doing anything with money.
	if inserted := recordDelivery(event.ID); !inserted {
		w.WriteHeader(http.StatusOK)
		return
	}

	if event.Type == dominaite.EventPaymentSucceeded {
		// Queue the work. Do not fulfil the order on this goroutine.
		enqueueFulfilment(event.Data.TransactionID, event.Data.IdempotencyKey)
	}

	// Answer fast. A slow 2xx is counted as a failed delivery and retried.
	w.WriteHeader(http.StatusOK)
}

A runnable version, with every event type handled, is in examples/webhook-handler/main.go:

export DOMINAITE_WEBHOOK_SECRET=whsec_...
go run ./examples/webhook-handler

That is the whole integration: the session call, the script tag, the webhook, and your domain bound to your checkout by Dominaite during onboarding.

Webhooks

VerifyWebhook authenticates a delivery and returns the parsed event. It verifies before it parses, and a WebhookEvent cannot be obtained any other way, so an unverified payload cannot reach your business logic by accident.

event, err := dominaite.VerifyWebhook(payload, signatureHeader, secret, opts...)
Argument What
payload The raw request body, byte for byte as received. Not a re-encoded struct.
signatureHeader The X-Webhook-Signature value, also available as dominaite.WebhookSignatureHeader.
secret The endpoint's whsec_... secret.

Options: WithWebhookTolerance(d) changes the freshness window from its 300 second default (dominaite.DefaultWebhookTolerance), and WithWebhookClock(fn) replaces the clock so tests can verify a recorded delivery at a fixed instant.

There is no way to switch the freshness check off. WithWebhookTolerance(0) is the strictest setting, not an off switch: it requires t to equal your clock to the second, which rejects anything that spent time in flight. A negative duration is refused with INVALID_TOLERANCE.

Getting the raw body right

This is the one thing that reliably goes wrong. The signature covers the exact bytes that were sent, so anything that reserialises the body invalidates it. Read the body first, verify, then parse. If a framework or middleware has already decoded the body for you, reach for its raw-body escape hatch rather than re-marshalling the decoded value.

The signature scheme

The header is t={unix_seconds},v1={lowercase_hex}. v1 is HMAC-SHA256 over the ASCII concatenation "{t}.{raw_body}", keyed with the UTF-8 bytes of the whsec_ secret. The SDK compares in constant time and then checks that |now - t| is within tolerance.

The timestamp is checked after the MAC, deliberately: t is covered by the signature, so until the MAC passes there is no reason to trust it. That ordering also means a stale but genuine replay reports TIMESTAMP_OUT_OF_TOLERANCE while a forgery reports SIGNATURE_MISMATCH, instead of the two failure modes blurring together.

If deliveries start failing with TIMESTAMP_OUT_OF_TOLERANCE, your server clock has drifted. Fix NTP rather than widening the tolerance.

Rejections

Every failure is a *WebhookVerificationError with a Reason. Nothing else comes out and nothing panics, so a hostile header is an ordinary rejection path:

var verr *dominaite.WebhookVerificationError
if errors.As(err, &verr) {
	log.Printf("rejected: %s", verr.Reason)
	w.WriteHeader(http.StatusBadRequest)
	return
}
Reason Meaning
MALFORMED_SIGNATURE Header missing, empty, or not t=...,v1=.... Nothing could be checked.
SIGNATURE_MISMATCH The MAC did not match. Wrong secret, or the payload is not what was signed.
TIMESTAMP_OUT_OF_TOLERANCE MAC valid, timestamp too old or too far ahead. A replay, or clock drift.
MALFORMED_PAYLOAD Signature good, body not valid JSON. Surprising - log it rather than dropping it.

Answer a rejected delivery with 400 and no detail. Never echo the reason back: whoever sent an unverified request does not get to learn whether the secret or the timestamp was the problem.

The event

The envelope is flat. There is no ApiResponse wrapper and no success field, so do not branch on one.

event.ID                     // delivery id, and YOUR DEDUPE KEY
event.Type                   // one of the Event* constants
event.APIVersion             // dated payload version, e.g. "2026-09-25"; empty from older gateways
event.CreatedAt              // ISO 8601 UTC instant of the transition, not of delivery
event.Data.TransactionID
event.Data.OrderReference    // your own order reference: match deliveries to orders on it
event.Data.OrderID           // hosted checkout order id; empty on refunds and cancellations
event.Data.Description       // what you sent on create session; empty when none
event.Data.PaymentMethod     // payment.* and verification.*: one of the PaymentMethod* constants; empty when null
event.Data.WalletType        // which wallet when PaymentMethod is "wallet", one of the WalletType* constants
event.Data.PaymentMethodBrand  // lower-cased card brand once a card was tried; empty otherwise
event.Data.PaymentMethodLast4  // last four card digits, same conditions as the brand
event.Data.Status            // wire status, one of the Status* constants
event.Data.PreviousStatus    // empty when the wire sent null
event.Data.Amount            // MINOR units: what you are PAID
event.Data.GrossAmount       // MINOR units: what the card was charged
event.Data.SurchargeAmount   // *int64, nil when no surcharge is known
event.Data.Currency
event.Data.OriginalTransactionID  // parent, on refunds and reversals
event.Data.IdempotencyKey    // your own mint key, when the gateway knows it
event.Data.PSPReference      // processor reference on payment.* events; empty until known
event.Data.StoredPaymentMethod  // *StoredPaymentMethod on payment.* events; nil when none
event.Data.VerifyOnly        // true on every event of a verify-only session
event.Data.DeclineClass      // on payment.failed, verification.failed and charge.*; can be empty
event.Data.Sequence          // per-object order on agreement.* and charge.*; 0 when absent
event.Data.Charge            // *ChargeWebhookData on charge.* events; nil on every other event
event.Data.Agreement         // *AgreementWebhookData on agreement.* events; nil on every other event
event.Data.Raw               // the data object, for fields not modelled above
event.Raw                    // the verified bytes, for anything not modelled above

Charge and Agreement are also nil when the data does not fit their struct. The event still verifies; read event.Data.Raw then.

APIVersion is the dated version of the payload shape. Fields are only ever added under a version, never renamed or removed, so ignore fields you do not recognise. A retry resends the bytes of the first attempt, so a redelivered event keeps its APIVersion.

Amount and GrossAmount are not the same number when a surcharge applies: Amount is what you are paid, GrossAmount is what moved on the card. Credit orders from Amount.

SurchargeAmount is a pointer so that "no surcharge information" stays distinct from "a surcharge of zero".

IdempotencyKey is the cheapest way to match a delivery back to your own order without a lookup. It is empty when the gateway does not know it, which today includes every refund.

Every payment.* event carries the identification fields TransactionID, OrderReference, OrderID, PSPReference, Description, PaymentMethodBrand, PaymentMethodLast4, IdempotencyKey and OriginalTransactionID. Match deliveries to your orders on OrderReference: refund and cancel events carry the original payment's reference, with the refunded payment in OriginalTransactionID. A null on the wire reads as an empty string.

StoredPaymentMethod is the card a SaveCard session stored: the same StoredPaymentMethod type GetStatus returns, so its ID is what ChargePaymentMethod takes. It is set on payment.succeeded (and payment.requires_capture for an authorization) when the card was stored together with the approval, and nil when no card was saved and on every other event. It can also be nil when a card was saved: on server-to-server sales that succeeded synchronously and on sales settled by the background sweep, the card is stored after the approval was announced. GetStatus is the source of truth, so on a SaveCard session whose event has StoredPaymentMethod == nil, read the status to pick the card up. charge.* events carry the stored payment method id instead, on Data.Charge.StoredPaymentMethodID.

PaymentMethod says how the payer paid on payment.* and verification.* events, one of PaymentMethodCategories. WalletType is set when it is wallet. On a charge.* event paymentMethod is an object instead, so Data.PaymentMethod stays empty there and the card is on Data.Charge.PaymentMethod.

Data.Charge types the charge.* payload: ChargeID, TransactionID, StoredPaymentMethodID, AgreementID, Outcome (one of the ChargeOutcome* constants), PeriodNumber and AttemptNumber (pointers, nil on a one-off charge), Amount, Currency, PaymentMethod (brand and last four, nil when unknown), DeclineClass, DeclineCode, NextAttemptAt, NextChargeAt and Sequence. Data.Agreement types the agreement.* payload: ID, PlanID, Status, PreviousStatus, the amount and interval, NextChargeAt, ActivatedAt, CancelledAt, Version and Sequence. Both keep their own Raw. A null on the wire reads as an empty string or a nil pointer.

DeclineClass is one of DeclineClasses on payment.failed, verification.failed, charge.failed and charge.retrying. It can be empty, for example when nobody declined the payment. An empty class is not hard. VerifyOnly is true on every event of a verify-only session.

Event catalog
Group Event types
Payments payment.succeeded, payment.failed, payment.requires_capture, payment.cancelled, payment.abandoned, payment.refunded, payment.disputed
Subscriptions and saved-card charges agreement.activated, agreement.past_due, agreement.cancelled, charge.succeeded, charge.failed, charge.retrying
Verify-only sessions verification.succeeded, verification.failed

All of them are exported as the Event* constants. Endpoint registration accepts these types and refuses any other. The match is case-sensitive.

  • payment.succeeded is the only signal that means money is in hand.
  • payment.refunded fires once per refund, from the refund row - never from the parent payment's status flip.
  • payment.requires_capture includes approved pre-auth holds. It is not an unpaid order.
  • payment.cancelled is a pre-completion void only.
  • payment.abandoned is the terminal sweep verdict on a checkout that was never paid.
  • agreement.past_due means that the platform gave up on the current period on this card. The agreement stays past_due until the period is paid on another card, or until you cancel it. Nothing cancels it automatically.
  • charge.failed means that nothing more is tried for this charge. For a one-off charge it can arrive again for the same chargeId with a new decline class: keep the latest one.
  • charge.retrying means that an agreement period was declined softly, and the platform tries the same card again. The agreement stays active.
  • A verify-only session never fires payment.* events. verification.failed fires when the issuer declined or the payer abandoned the checkout. No card is saved.

pending and processing are deliberately not webhooked. Poll session status if you want to drive in-flight UX off them.

Treat a Type you do not recognise as a no-op and still answer 2xx. The catalog can grow, and a 400 on an unknown type will trip the circuit breaker on your endpoint.

Ordering agreement and charge events

agreement.* and charge.* deliveries carry data.sequence, parsed into event.Data.Sequence.

Deliveries can arrive out of order. Keep the highest sequence you have processed per object and discard any event whose sequence is not higher; when you need current state, read the object by id. createdAt can repeat across events, so order by sequence, not createdAt. A sequence of 0 only comes from events recorded before the counter existed; treat it as older than any positive number.

The object is:

Event Object key
agreement.* the agreement, data.id
charge.* placed by the platform for an agreement the agreement period, data.agreementId + data.periodNumber
charge.* for a one-off charge you initiated data.chargeId

Read the key from the typed payload:

var key string
switch {
case event.Data.Agreement != nil:
	key = event.Data.Agreement.ID
case event.Data.Charge != nil && event.Data.Charge.AgreementID != "" && event.Data.Charge.PeriodNumber != nil:
	key = fmt.Sprintf("%s/%d", event.Data.Charge.AgreementID, *event.Data.Charge.PeriodNumber)
case event.Data.Charge != nil:
	key = event.Data.Charge.ChargeID
}

// In the same transaction as the work itself:
if event.Data.Sequence <= lastSequence(key) {
	return nil // stale or repeated, answer 2xx and drop it
}

payment.* events carry no sequence; event.Data.Sequence is 0 for them.

Delivery semantics

Delivery is at-least-once. Dedupe on event.ID, persistently - an in-memory set loses on restart, and retries can arrive hours apart.

Respond 2xx quickly and queue the real work. A slow response counts as a failed delivery.

Failed deliveries are retried up to the endpoint's RetryCount (default 3, max 10, 0 disables) at 1m, 5m, 30m, 2h, 12h. An endpoint whose initial attempt and every configured retry fail consecutively is auto-disabled; a later successful delivery re-enables it. An endpoint you disabled by hand in the dashboard is never re-enabled automatically.

A merchant can have at most 25 active endpoints.

Reconciliation is still mandatory

Webhooks complement the reconciliation sweep, they do not replace it. There is no publish outbox, and chains parked against a disabled endpoint are simply lost, so there are windows in which a delivery never arrives at all. Keep a scheduled job that reads back the status of every order you believe is still open and settles it from the API.

If you do only one of the two, do reconciliation. It is the one that cannot silently lose money.

Rotating a secret

Regenerating an endpoint's secret replaces it: the old secret stops verifying immediately. There is no overlap window, and VerifyWebhook rejects a header carrying more than one v1 rather than trying candidates in turn. Update the secret in your configuration as part of the same change that regenerates it.

Managing endpoints from code

You can register endpoints on the Webhooks tab, or from code. The Webhooks tab needs no key. From code, the webhook endpoint routes need a reporting key with the webhooks:manage scope.

reporting, err := dominaite.New(reportingKeyID, reportingSecret, dominaite.WithBaseURL(baseURL))
endpoint, err := reporting.CreateWebhookEndpoint(ctx, dominaite.CreateWebhookEndpointParams{
	URL:            "https://shop.example/hooks/dominaite",
	Events:         []string{dominaite.EventPaymentSucceeded, dominaite.EventPaymentRefunded},
	IdempotencyKey: "webhook-orders-2026", // a fresh key for each new endpoint
})
// endpoint.Secret == "whsec_...": store it now, it is never shown again

Secret is in the first answer only. A replay of the same key, the list and every other answer leave it empty. Lost the secret? Delete the endpoint and create it again. Printing a WebhookEndpoint never shows the secret, but Raw of the first answer holds it: never log Raw.

IdempotencyKey is required and signed. After no answer, a timeout or a 5xx, retry with the same key. When the first attempt never finished, the retry replaces the endpoint it left and answers a new id and a new secret. When the endpoint was registered and only its answer was lost, the retry answers that endpoint with an empty Secret: delete it with DeleteWebhookEndpoint, then create it again with a new key. One key never holds two endpoints. A 503 can last a while: retry for about five minutes, then check ListWebhookEndpoints and delete any endpoint you never got a secret for. The same key with other URL or events is refused with IDEMPOTENCY_KEY_REUSED. A key used again more than a day later, while its endpoint still exists, is refused with WEBHOOK_ALREADY_REGISTERED:

var apiErr *dominaite.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode == dominaite.ErrorCodeWebhookAlreadyRegistered {
	var envelope struct {
		Error struct {
			Params struct {
				WebhookID string `json:"webhookId"`
			} `json:"params"`
		} `json:"error"`
	}
	_ = json.Unmarshal(apiErr.Raw, &envelope) // envelope.Error.Params.WebhookID is the endpoint
}

ListWebhookEndpoints returns your endpoints, newest first, without secrets: every active endpoint and your newest 1000 inactive ones. DeleteWebhookEndpoint stops the deliveries at once. An id that is unknown or already deleted answers a 404 *APIError with ErrorCode WEBHOOK_NOT_FOUND: after a lost answer to a delete, treat that as done.

Coded refusals are an *APIError with HTTPStatus and ErrorCode. Nothing was registered under any of them:

Code HTTP Meaning
WEBHOOK_URL_REJECTED 400 The URL is not https, is malformed, does not resolve, or points at a private host.
WEBHOOK_LIMIT_REACHED 409 You already have the maximum of 25 active endpoints. Delete one first.
WEBHOOK_ALREADY_REGISTERED 409 This key registered an endpoint more than a day ago. Use a fresh key.
WEBHOOK_NOT_FOUND 404 DeleteWebhookEndpoint: no endpoint with this id under your account.
IDEMPOTENCY_KEY_REUSED 422 The key was first used for other URL or events.
DUPLICATE_REQUEST 409 A request with this key is in progress. Retry with the same key after a second.
Testing your handler

The suite in this repo pins the canonical cross-SDK vector, byte for byte, along with tamper, wrong-secret, stale-timestamp and malformed-header cases. Every Dominaite SDK pins the same vector, so the recipe cannot drift between languages. Run go test ./... before you point a real endpoint at your handler.

To test your own handler against a fixed delivery, sign a body yourself and pin the clock:

event, err := dominaite.VerifyWebhook(
	body,
	header,
	secret,
	dominaite.WithWebhookClock(func() time.Time { return time.Unix(1755700000, 0) }),
)

Client options

dominaite.New(keyID, secret, opts...) takes functional options. WithBaseURL is required:

Option What
WithBaseURL(url) Required. dominaite.SandboxBaseURL or dominaite.ProductionBaseURL, or your own value. Without it, or with an empty or whitespace value, New returns a *ValidationError. Must be https://; plain http:// is accepted only for localhost, 127.0.0.1 and ::1, and New returns a *ValidationError for anything else.
WithTimeout(d) Per-request timeout on the default HTTP client. Defaults to 45s (serverless cold starts can take 10+s).
WithHTTPClient(c) Your own *http.Client: proxy-aware transport, custom TLS, a test double. Replaces WithTimeout.
WithUserAgent(s) Appends your identifier to the SDK's User-Agent, which helps when support reads the access logs. The SDK sends dominaite-go/<version> (go <go version>) <your suffix>. A suffix with a control character makes New return a *ValidationError.
WithLogger(l) A *slog.Logger for the SDK's warnings and debug lines. See Logging and warnings.
WithMaxRetries(n) How many times the client retries a failed call after the first attempt. Defaults to 2; 0 turns retries off. A negative value makes New return a *ValidationError. See Automatic retries.

DefaultBaseURL is removed in 0.6.0. Use ProductionBaseURL.

Every call takes a context.Context. A context deadline shorter than the client timeout wins, and cancelling the context returns a *TransportError wrapping context.Canceled. The timeout applies to each attempt, so with retries one call can take longer than the timeout; see Automatic retries.

Logging and warnings

The SDK logs warnings at slog.LevelWarn. Without WithLogger they go to slog.Default(). It warns about three things:

  • The API sends a Dominaite-Sdk-Notice response header when your SDK version is deprecated. The SDK warns once for each distinct value, for example dominaite-go 0.6.0 is deprecated; upgrade to 0.7.0 (sunset 2027-01-31). https://.... Without the header, nothing happens.
  • The client warns before each retry, with the call, the error code or type, and the attempt number.
  • Ping warns when the clock skew is above 60 seconds. Requests fail at 300.

The debug log is off by default. Pass your own logger with WithLogger, and let its handler show slog.LevelDebug. The SDK then logs one line per request:

dominaite-go: POST /merchant-api/checkout/sessions 201 142ms request_id=... idempotency_key=...

The line holds the method, the signed path, the status (error when the network failed), the time in milliseconds, the request id and the idempotency key you sent. Each retry gets its own line. The request id is the Dominaite-Request-Id response header, or metadata.requestId in the body when the header is absent. Give the request id to support when you ask about a call. The default logger never gets debug lines.

No warning and no debug line holds your secret, the signature, the cashier token, a query string or a request or response body. A handler that panics does not fail the request.

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
client, err := dominaite.New(keyID, secret,
	dominaite.WithBaseURL(dominaite.SandboxBaseURL),
	dominaite.WithLogger(logger),
)

Amounts are minor units

Amount is always an integer in the currency's minor unit: 2500 is 25.00 EUR. The field is an int64, so a float will not compile; non-positive values are rejected before anything reaches the network. The one exception is a verify-only session, whose amount is 0. The amount is locked server-side - what you pass here is what gets charged; nothing in the browser can change it.

Prices usually live as decimals in your catalog. Convert them with ToMinorUnits, which does it exactly on the digits, never through a float, by the exponent the gateway uses for the currency:

amount, err := dominaite.ToMinorUnits("0.30", "EUR") // 30
amount, err = dominaite.ToMinorUnits("1500", "JPY")   // 1500 (no minor unit)
amount, err = dominaite.ToMinorUnits("1500", "HUF")   // 1500 (whole forints, see below)
amount, err = dominaite.ToMinorUnits("1.250", "KWD")  // 1250 (three decimals)

It knows EUR, USD, GBP, CAD, AUD, CHF, BGN, RON, PLN, CZK, SEK, DKK, NOK (2 decimals), JPY and HUF (0), and BHD and KWD (3); CurrencyExponent exposes the table. HUF is whole forints on the gateway, although ISO 4217 gives it 2 decimals: send 1500 for 1500 Ft, not 150000. ISK, KRW, OMR, JOD and TND are refused as not supported, because ISO 4217 and the gateway disagree on them and either reading would be off by 10x or 100x. An unknown currency, a malformed amount (sign, comma, thousands separator) or more decimals than the currency allows, zeros included ("25.000" EUR, "100.0" JPY), is a *ValidationError, never a silent rounding.

Retries and double-charges

Every CreateCheckoutSession and ChargePaymentMethod call needs an IdempotencyKey. The SDK never makes one up: an empty or blank key is a *ValidationError and nothing is sent. Derive the key from the order with OrderIdempotencyKey:

key, err := dominaite.OrderIdempotencyKey("checkout", order.ID, amount, "EUR")
// "checkout-{orderId}-{amountMinor}-{CURRENCY}", e.g. "checkout-order-1042-2500-EUR"

Why order-derived: your payment page will be re-entered (reload, Back button, a second tab, a retry after a timeout). The same order at the same amount gives the same key, and the gateway answers a replayed key with the session that is already open: same TransactionID, same cashier handles, render the widget again. A random key per call opens a new session on every view instead, and the order ends up pointing at the wrong one. When the amount or currency changes (the cart was edited), the helper gives a new key and you get a fresh session; replaying the old key with the new amount would be refused with IDEMPOTENCY_KEY_REUSED.

scope is a fixed label for the call site ("checkout", "renewal") so two kinds of payment for one order never share a key. SaveCard is part of a session's identity too: if the payer can flip it after a session was opened, use a different scope per choice (for example "checkout-save"). Keys are case-insensitive on the gateway, and the helper uppercases the currency. A key is 1 to 100 visible ASCII characters (0x21 to 0x7E): no spaces, no control characters, no non-ASCII letters. The helper checks its output against that rule, so an order id with a space or a Cyrillic letter is a *ValidationError, not a silently rewritten key.

When the earlier attempt already moved money, ended, or cannot be handed back yet, the replay comes back as a *RefusalError (ALREADY_PROCESSED, PRIOR_ATTEMPT_FAILED, DUPLICATE_REQUEST). Recover through RefusalError.TransactionID and GetStatus - see Recovering from a replay refusal below.

Automatic retries

The client retries a failed call on its own, with your key on every attempt. One policy covers every read (GET) and every POST that carries an idempotency key: sessions, charges, refunds, plans and agreements.

  • What is retried: a *TransportError (a network failure, a timeout or a 5xx, including MERCHANT_API_UNAVAILABLE and INTERNAL_ERROR) and PAYMENT_PROCESSING_UNAVAILABLE, both the 503 and the HTTP 200 session refusal. After the last attempt you get the last error back.
  • What is not retried: a 429 (*RateLimitError, with RetryAfterSeconds kept for you), any 4xx, any other refusal, CHARGE_OUTCOME_UNKNOWN, CHARGE_FAILED and the charge route's 503 PAYMENT_METHOD_CHARGES_DISABLED. They will not change on a retry, and retrying into a full queue only makes it longer. PAYMENT_METHOD_CHARGES_DISABLED holds until saved-card charges are switched on for your account, so retry it yourself later with the same idempotency key.
  • Not covered: CreateWebhookEndpoint is never retried, whatever the budget, because a retry can answer the endpoint without its secret. DELETE and PATCH calls (RevokePaymentMethod, DeletePlan, CancelAgreement, DeleteWebhookEndpoint, UpdatePlan) are not retried either.
  • Same request every time: each attempt sends the same idempotency key and the same body. Only the timestamp and the signature over it change.
  • Budget: WithMaxRetries(n) sets how many retries follow the first attempt. The default is 2, so a call is sent at most 3 times. WithMaxRetries(0) sends every call once.
  • Waits: the wait before retry n (0 for the first) is a random time between 0 and 500ms x 2^n, capped at 8 seconds. A cancelled context stops the wait at once.

The timeout is per attempt. With WithTimeout(t) and WithMaxRetries(n), one call can take up to (n + 1) x t plus the waits between attempts: at the defaults, 3 x 45s plus up to 1.5s of waits. Put a deadline on the context when a caller waits on the answer.

client, err := dominaite.New(keyID, secret,
	dominaite.WithBaseURL(dominaite.ProductionBaseURL),
	dominaite.WithMaxRetries(2), // the default; 0 turns retries off
)

CreateCheckoutSessionWithRetry and RetryOptions are deprecated: CreateCheckoutSession retries on its own now. The method still works with its own budget: Attempts is the total number of attempts (zero uses the client's budget) and BaseDelay replaces the 500ms base.

Sessions expire

A session is valid for 2 hours. If the payer comes back later, re-POST CreateCheckoutSession with the same order-derived idempotency key: within a few minutes of expiry that answers DUPLICATE_REQUEST (retry the same key shortly), and past that it succeeds with a fresh session. See Recovering from a replay refusal.

Card fields

Card fields put the card form in your own page instead of the hosted widget. They are enabled per merchant on request: ask Dominaite support. On an account without them, IntegrationFields is a 400 INVALID_SELECTION *APIError.

Ask for them when you create the session:

session, err := client.CreateCheckoutSession(ctx, dominaite.CreateCheckoutSessionParams{
	Amount:         2500,
	Currency:       "EUR",
	OrderReference: "order-1042",
	IdempotencyKey: key,
	Integration:    dominaite.IntegrationFields, // empty = IntegrationWidget
})

session.Integration echoes what the session was created for. A fields session also carries ClientSecret, the browser credential for that one session. Hand TransactionID, Integration, CashierKey, CashierToken and ClientSecret to the payment page, escaped like any templated value, and never log the secret. Integration is part of the idempotency identity: the same key with a different integration is IDEMPOTENCY_KEY_REUSED.

<div id="checkout"></div>
<script src="https://pay.dominaite.com/v1/checkout.js"></script>
<script>
  const checkout = Dominaite.checkout({
    transactionId: "TRANSACTION_ID_FROM_SESSION",
    integration: "fields",
    cashierKey: "CASHIER_KEY_FROM_SESSION",
    cashierToken: "CASHIER_TOKEN_FROM_SESSION",
    clientSecret: "CLIENT_SECRET_FROM_SESSION",
  })
  checkout.on("success", () => { /* show the thank-you screen */ })
  checkout.mount("#checkout")
</script>

Browser events are for the payer's screen only. Mark the order paid only from the payment.succeeded webhook or a GetStatus read, exactly as with the widget.

Stored payment methods (recurring)

Set SaveCard: true when you create a session and, once that payment is approved, the gateway keeps the card on file. You never see the card number or the provider token: GetStatus returns a StoredPaymentMethod with an opaque ID (pm_ + 32 hex characters), the Brand, the Last4 and the expiry, and that ID is what you charge and revoke with. Store it against your customer. (The PaymentMethod field on the same status is something else: the string category of how the payer paid, card, wallet and so on. See Status polling.)

Saving a card works on every account. We switch saved-card charges on per account: ask us. Until then, ChargePaymentMethod answers a 503 *ChargeError with ErrorCode PAYMENT_METHOD_CHARGES_DISABLED. It also answers this code when we pause charges for a short time. The code is the same in both cases. If we never switched saved-card charges on for your account, a retry does not help: ask us. If they are on, retry later with the same idempotency key.

key, err := dominaite.OrderIdempotencyKey("checkout-save", "sub-8817-first", 2500, "EUR")
session, err := client.CreateCheckoutSession(ctx, dominaite.CreateCheckoutSessionParams{
	Amount:         2500,
	Currency:       "EUR",
	OrderReference: "sub-8817-first",
	SaveCard:       true,
	IdempotencyKey: key,
})
// ... the payer completes the hosted checkout ...
status, err := client.GetStatus(ctx, session.TransactionID)
if status.Status == dominaite.StatusSucceeded && status.StoredPaymentMethod != nil &&
	status.StoredPaymentMethod.Status == dominaite.StoredPaymentMethodStatusActive {
	db.SaveCard(customerID, status.StoredPaymentMethod.ID) // pm_...
}

// Later, off-session, no payer present:
charge, err := client.ChargePaymentMethod(ctx, paymentMethodID, dominaite.ChargePaymentMethodParams{
	Amount:         2500,
	Currency:       "EUR",
	OrderReference: "sub-8817-2026-10",
	Description:    "Monthly plan, October",
	IdempotencyKey: "sub-8817-2026-10", // derive it from the billing period, never random per attempt
})
var chargeErr *dominaite.ChargeError
if errors.As(err, &chargeErr) {
	switch chargeErr.ErrorCode {
	case dominaite.ChargeErrorOutcomeUnknown:
		// 502: the provider gave no verdict, the charge MAY have happened. Never retry
		// under a new key: poll the transaction the gateway attached instead.
		return pollUntilSettled(ctx, chargeErr.TransactionID)
	case dominaite.ChargeErrorChargesDisabled:
		// Nothing was charged. Saved-card charges never switched on for your account: a retry
		// does not help, ask us. Switched on: retry later with the SAME idempotency key.
	case dominaite.ChargeErrorDuplicateRequest, dominaite.ChargeErrorProcessingUnavailable:
		// Nothing was charged; retry later with the SAME idempotency key.
	case dominaite.ChargeErrorPaymentMethodNotActive:
		// Revoked, expired or retired: bring the customer back for a hosted session with SaveCard.
	case dominaite.ChargeErrorFailed:
		// 502, nothing was charged. chargeErr.Charge is set when a row exists.
	case dominaite.ChargeErrorIdempotencyKeyReused:
		// Same key, different body or method: a bug on your side.
	}
	return err
}
if err != nil {
	return err // not yours (404), validation, auth, rate limit, transport - see Errors
}

switch charge.Status {
case dominaite.ChargeStatusSucceeded:
case dominaite.ChargeStatusPending:
	// Not terminal. Poll GetStatus(charge.TransactionID), or wait for the webhook.
case dominaite.ChargeStatusFailed:
	// HTTP 402 from the gateway, but not an error: branch on the class, log the code.
	// An empty class is not hard: if you retry, retry as for DeclineClassSoftOther.
	// DeclineClassHard            - give up on this card, ask the customer for another one
	// DeclineClassSoftFunds       - insufficient funds, retry later (not in a loop)
	// DeclineClassSoftSCARequired - the issuer wants the customer present: send them through a
	//                               hosted session with SaveCard and charge the new method
	// DeclineClassSoftOther       - transient, one retry later is reasonable
	handleDecline(charge.DeclineClass, charge.DeclineCode)
case dominaite.ChargeStatusCancelled:
	// An authorization voided before capture; no money moved.
}

// When the customer removes the card:
err = client.RevokePaymentMethod(ctx, paymentMethodID) // 204, returns nil; 204 again if already revoked
var revokeErr *dominaite.RevokeError
if errors.As(err, &revokeErr) && revokeErr.ErrorCode == dominaite.RevokeErrorMerchantAPIUnavailable {
	// 503: nothing changed, retry later.
} else if revokeErr != nil {
	// 502 UPSTREAM_CONTRACT_ERROR: the provider refused for good, nothing changed. Contact support with the id.
}

A charge is signed exactly like a session and carries an Idempotency-Key, so a retry after a timeout with the same key never charges the card twice: the gateway replays its first answer, HTTP status included. The HTTP status is the contract on this route: 201 (or 200 on a replay) returns the charge, 402 returns the charge too (Status failed), and 409, 422, 502 and 503 return a *ChargeError with ErrorCode, HTTPStatus, the gateway's message and, when the gateway attached the charge row, Charge and TransactionID. Only authentication (401/403), an id that is not yours (404, *APIError with ErrorCode PAYMENT_METHOD_NOT_FOUND), validation (400, *APIError), rate limiting (429) and network failures or a 5xx without a code keep their generic errors. DeclineClass and DeclineCode are empty unless the charge failed. DeclineClass can be empty, for example when nobody declined the payment. An empty class is not hard; if you retry, retry as for soft_other.

Set AgreementID to charge under an agreement. The charge must then be on the agreement's terms: its payment method, its consent, its currency and its amount rule. A charge off those terms is refused with a 422 *ChargeError whose ErrorCode names the rule. An agreement that is not yours is a 404 *APIError with ErrorCode AGREEMENT_NOT_FOUND.

Revoking signs an empty key and an empty body, like GetStatus. A revoke that fails with *RevokeError changed nothing: MERCHANT_API_UNAVAILABLE (503) is retryable, UPSTREAM_CONTRACT_ERROR (502) is not. After a revoke the status read keeps the StoredPaymentMethod with Status revoked, and a charge against it is refused with PAYMENT_METHOD_NOT_ACTIVE. An id that is not yours is an *APIError with HTTPStatus 404.

Verify-only sessions

A verify-only session saves the payer's card without a payment. Use it to put a card on file before the first charge, for example at sign-up.

Verify-only sessions are off by default. We switch it on for your account. Until then, a verify-only session is refused with a 403 *AuthError with ErrorCode VERIFY_ONLY_NOT_ENABLED. Nothing is created, and the idempotency key is not spent.

session, err := client.CreateCheckoutSession(ctx, dominaite.CreateCheckoutSessionParams{
	// Amount stays 0: the platform sets the verification amount.
	Currency:       "EUR",
	OrderReference: "card-check-customer-8817",
	VerifyOnly:     true,
	IdempotencyKey: "verify-customer-8817", // derive it from the customer, never per call
})
  1. The checkout places a small authorization to check the card, and saves the card.
  2. The platform releases the authorization within about 15 minutes. You and the payer are never charged.
  3. GetStatus shows the result, with VerifyOnly true. A non-nil StoredPaymentMethod says that the card is verified. Status stays the raw status: requires_capture, then cancelled when the authorization is released.
  4. The session sends a verification.succeeded or verification.failed webhook event. It never sends payment.* events.

Amount must be 0. SaveCard is implied. Integration must be empty or IntegrationWidget, and AgreementID must be empty. The SDK refuses a negative amount before it sends anything. The API refuses the other combinations with a 400.

Subscriptions: plans and agreements

A plan is a price and a billing interval you sell. An agreement subscribes one customer to a plan. The plan and agreement routes need a key with the payments scope. We switch subscription sessions on for your account. Scheduled charges are a separate switch. Ask us for both.

Plans
plan, err := client.CreatePlan(ctx, dominaite.CreatePlanParams{
	Name:           "Gold",
	Amount:         2500, // per period, in minor units
	Currency:       "EUR",
	IntervalUnit:   dominaite.IntervalUnitMonth,
	IntervalCount:  3, // every 3 months; 0 sends nothing and the API uses 1
	TrialDays:      14,
	CancelURL:      "https://shop.example/account/cancel",
	IdempotencyKey: "plan-gold-quarterly", // derive it from YOUR plan record
})
// plan.ID == "plan_...": store it exactly as it is, it is case-sensitive

IdempotencyKey is required and signed. The same key with the same body answers the same plan (HTTP 200 instead of 201), so a create whose answer you lost is safe to repeat. The same key with a different body is refused with IDEMPOTENCY_KEY_REUSED.

Amount, currency and interval never change. To change a price, create a new plan, archive the old one, and move your customers deliberately. UpdatePlan changes only the name, the trial for agreements created from now on, and the cancel page. A nil field keeps its value:

name, noCancelPage := "Gold plus", ""
plan, err = client.UpdatePlan(ctx, plan.ID, dominaite.UpdatePlanParams{
	Name:      &name,
	CancelURL: &noCancelPage, // "" removes the cancel page
})

UpdatePlan sends no idempotency key: the route takes none. DeletePlan archives the plan: it accepts no new agreements, and its agreements continue unchanged. Deleting an archived plan succeeds again. ListPlans returns at most 200 plans, active first, then newest first. It has no pages: read a plan by id with GetPlan.

A plan that is not yours is a 404 *APIError with ErrorCode PLAN_NOT_FOUND. A field the API refused is a 400 *APIError, and Raw carries error.validationErrors. A 5xx is a *TransportError: nothing was created, so retry with the same key.

Agreements

An agreement subscribes one customer to an active plan. It is created pending, and nothing is charged:

agreement, err := client.CreateAgreement(ctx, dominaite.CreateAgreementParams{
	PlanID:            plan.ID,
	CustomerReference: "customer-8817", // your own id for the customer
	IdempotencyKey:    "subscription-8817-gold",
})
// agreement.ID == "agr_...", agreement.Status == dominaite.AgreementStatusPending

The subscription session takes the first payment, saves the card and activates the agreement. It is CreateCheckoutSession with AgreementID and Consent:

session, err := client.CreateCheckoutSession(ctx, dominaite.CreateCheckoutSessionParams{
	Amount:         agreement.Amount, // exactly the agreement's amount on a plan without a trial
	Currency:       agreement.Currency,
	OrderReference: "subscription-8817-first",
	AgreementID:    agreement.ID,
	Consent: &dominaite.Consent{
		TextVersion: dominaite.ConsentTextVersionRecurring202609, // the platform text your page showed
		AcceptedAt:  "2026-10-01T09:30:00Z",
		IP:          customerIP,
	},
	IdempotencyKey: "subscription-8817-first",
})

On a plan without a trial, the session's payment is the first period. On a plan with a trial, the session charges the amount you send. With scheduled charges on, the first scheduled charge comes when the trial ends. The session saves the card whatever SaveCard says. A session refusal is a *RefusalError with one of the SubscriptionSessionRefusalCodes. Until we switch subscription sessions on, CreateCheckoutSession with AgreementID answers SUBSCRIPTIONS_NOT_ENABLED. The agreement then stays pending.

With scheduled charges on, the platform charges the card each period after activation. It sends a charge.* webhook event for each of those charges. Follow the agreement with the agreement.* webhook events, or read it with GetAgreement. GetStatus on the session's transaction shows the agreement as Agreement: its ID, Status and NextChargeAt. Status stays pending after a declined or abandoned session, which means that the subscription did not start.

CreateAgreement also takes StoredPaymentMethodID, the pm_ id of a card the customer already saved. Consent is then required: your own label for the wording the customer accepted, when, and from which IP address. A variable-amount subscription (seat plus usage) sets MaxAmountPerPeriod, the most one charge may be. It also requires Consent. A subscription session cannot take the first payment of a variable agreement: it refuses with AGREEMENT_VARIABLE_AMOUNT_UNSUPPORTED.

CancelAgreement cancels from pending, active or past_due: no further charge is scheduled, and a charge already placed is not reversed. Cancelling a cancelled agreement succeeds again. The platform never cancels an agreement on its own: a past_due agreement stays past_due until the period is paid on another card or you cancel it. PastDueReason says why it is past due.

Coded refusals of the plan and agreement routes are an *APIError with HTTPStatus and ErrorCode. None of them changes on a retry:

Code HTTP Meaning
PLAN_NOT_FOUND 404 No plan with this id under your account.
PLAN_NOT_ACTIVE 409 CreateAgreement: the plan is archived.
AGREEMENT_NOT_FOUND 404 No agreement with this id under your account.
AGREEMENT_ENDED 409 CancelAgreement: the agreement already completed or failed.
PAYMENT_METHOD_NOT_FOUND 404 No stored payment method with this id under your account.
PAYMENT_METHOD_NOT_ACTIVE 409 The stored card is revoked, expired or retired.
PAYMENT_METHOD_CURRENCY_MISMATCH 422 The card was saved from a payment in another currency than the plan's.
PAYMENT_METHOD_CURRENCY_UNKNOWN 422 The currency of the card cannot be established.
MAX_AMOUNT_BELOW_PLAN_AMOUNT 422 MaxAmountPerPeriod is below the plan's amount.
AGREEMENT_CONSENT_REQUIRED 422 A stored card or a MaxAmountPerPeriod without Consent.
IDEMPOTENCY_KEY_REUSED 422 The key was first used for a different plan or agreement.

A field the API refused is a 400 *APIError, and Raw carries error.validationErrors.

Refunds

CreateRefund refunds a card-not-present payment of your own account, in full or in part. transactionID is the id CreateCheckoutSession or ChargePaymentMethod returned. The amount is in minor units of the payment's currency, so convert with ToMinorUnits (HUF has 0 decimals):

amount, err := dominaite.ToMinorUnits("1500", "HUF") // 1500
refund, err := client.CreateRefund(ctx, transactionID, dominaite.CreateRefundParams{
	Amount:         &amount, // nil refunds everything still refundable
	Reason:         "Returned item",
	IdempotencyKey: "refund-credit-note-77", // derive it from YOUR refund, never per attempt
})
// refund.RefundID == "re_...", refund.Status == dominaite.RefundStatusPending

CreateRefund on a card-present payment (made on a terminal) answers 404 PAYMENT_NOT_FOUND. Whoever holds the payments key can refund up to the paid amount, so keep the key on your server.

IdempotencyKey is required and signed, exactly as on a charge. Derive it from your return or credit-note id: the same key always answers the same refund and never refunds twice, so on a timeout retry with the same key. The same key with a different amount, reason or payment is refused with IDEMPOTENCY_KEY_REUSED.

Leave Amount nil for a full refund: the SDK then sends no amount at all. It is a pointer so that a partial refund that computed to zero is refused as a *ValidationError instead of turning into a full refund. Partial refunds add up, and the amount may not exceed what is left after earlier refunds and refunds still in progress. Reason is optional, at most 500 characters.

The gateway answers 202: the refund is queued, not done. Read it back with GetRefund, or wait for payment.refunded, which fires once the money has moved. On that event Data.TransactionID is the refund's own transaction, Data.Amount is the amount of that refund and Data.OriginalTransactionID is the payment you refunded. A failed refund fires no webhook, so poll GetRefund if you need to know about failures.

refund, err := client.GetRefund(ctx, transactionID, refundID) // the RefundID you stored
if dominaite.IsRefundTerminal(refund.Status) {
	// succeeded: *refund.Amount is what was refunded
	// failed: refund.FailureCode says why
}

Status is pending (queued), processing (with the payment provider), succeeded or failed (exported as the RefundStatus* constants); the last two are final. Amount is the amount requested on pending (nil for a full refund), the amount being refunded on processing (nil until a full refund has been sized), the amount actually refunded on succeeded, and always nil on failed. FailureCode is set on failed only: REFUND_AMOUNT_EXCEEDED, PAYMENT_NOT_REFUNDABLE or REFUND_FAILED (the RefundFailure* constants); treat a value you do not recognise as REFUND_FAILED. A failed refund is final for its key: a new attempt needs a new key. CompletedAt is set once the refund is final.

ListRefunds reads all refunds of one payment that were created through this API, and the amounts that decide the next refund:

list, err := client.ListRefunds(ctx, transactionID)
// list.RefundableAmount: what a new refund can take right now
// list.RefundedAmount: refunded so far, from every source
// list.Refunds: pending first, then processing, then succeeded and failed, newest first

A refund made in the dashboard or at the provider is not in Refunds, but RefundedAmount counts it. A new refund appears in the list at the same moment GetRefund stops answering REFUND_NOT_FOUND. ListRefunds signs an empty key and an empty body, like GetRefund, and returns the same errors.

Coded refusals are a *RefundError with HTTPStatus, ErrorCode (the RefundError* constants) and Retryable:

Code HTTP Meaning Retryable
PAYMENT_NOT_FOUND 404 No card-not-present payment with this id under your account. no
REFUND_NOT_FOUND 404 GetRefund only: not picked up yet right after the 202. Poll for up to 60 seconds. yes
PAYMENT_NOT_REFUNDABLE 422 Not paid, already fully refunded, or everything left is already being refunded. Nothing queued, key not burnt. no
REFUND_AMOUNT_EXCEEDED 422 More than what is left to refund; the message names the amount left. Nothing queued, key not burnt. no
IDEMPOTENCY_KEY_REUSED 422 The key was first used for a different amount, reason or payment. no
DUPLICATE_REQUEST 409 A request with this key is in flight. Retry with the same key after a second, for up to 120 seconds. yes
IDEMPOTENCY_KEY_REQUIRED 400 The Idempotency-Key header was missing or too long. no

A 5xx means nothing was queued: it is a *TransportError, retry with the same key. Authentication (401/403) and rate limiting (429) keep their generic errors.

Status polling (fallback, and the reconciliation sweep)

Prefer webhooks for learning that a payment resolved. Polling is the right tool for three narrower jobs: the reconciliation sweep, in-flight UX on pending and processing (which are never webhooked), and local development before you have a public URL to deliver to.

Reaching for polling as your primary signal means holding an order open until you happen to ask about it. Reaching for it as your only signal means a busy loop against a rate-limited endpoint. Use both: webhooks for latency, the sweep for completeness.

status, err := client.GetStatus(ctx, session.TransactionID)
// status.Status == "succeeded", status.OrderReference == "order-1042",
// status.Amount == 2500, status.Currency == "EUR", ...

Status is one of pending, processing, succeeded, failed, refunded, partially_refunded, cancelled, disputed, requires_capture, abandoned (exported as the Status* constants). While the session is still payable the response also carries ExpiresAt; after that instant a pending session can only become abandoned. An unknown transaction id returns an *APIError with HTTPStatus 404.

succeeded is the only value that means the payment is complete. Keep polling on pending, processing and requires_capture - none of them is terminal. IsPaid(status) and IsTerminal(status) encode exactly that: IsPaid is true for succeeded only; IsTerminal is true for succeeded, failed, cancelled, abandoned, refunded and partially_refunded, and false for everything else, unknown values included.

if dominaite.IsPaid(status.Status) {
	markPaid(order)
} else if !dominaite.IsTerminal(status.Status) {
	pollAgainLater(order)
}

PaymentMethod says how the payer paid (card, wallet, bank_transfer or sepa, exported as PaymentMethodCategories) and WalletType says which wallet when it is wallet (apple_pay, google_pay, samsung_pay, exported as WalletTypes). Both are empty while the payment is still open and on older transactions, and WalletType is empty for non-wallet payments. This is reporting data, not a money-flow switch: a wallet payment refunds, captures and disputes exactly like a card payment, so do not branch payment handling on it. A WalletType outside WalletTypes is a wallet this SDK does not name yet; treat it as valid, not as an error.

requires_capture is not "unpaid": the payer has already paid and the funds are held awaiting capture. Never treat it as an abandoned order.

Five fields describe declines, saved-card charges and subscriptions:

  • DeclineClass is one of DeclineClasses on a failed payment. It can be empty, for example when nobody declined the payment.
  • IsRecurring is true for a charge on a stored card without the payer present.
  • Agreement is the agreement of a subscription session or charge, nil on a one-off.
  • Sequence orders the read against the charge.* events of the same transaction. It is 0 on a checkout payment.
  • VerifyOnly is true on a verify-only session.

Treat any status you do not recognise as still-open as well: a value the API adds later should make you keep polling, never silently close an order that is still live.

Poll after the payer returns to you, or on your order timeout - not in a tight loop; the endpoint is rate limited at 60 requests per minute per key and 120 per minute per IP. Going over returns a *RateLimitError.

Both response types also carry Raw (json.RawMessage) with the unparsed payload, for fields the structs do not model yet.

Listing your transactions (reporting)

ListTransactions reads your card-not-present payments, one page at a time: checkout payments, saved-card charges, subscription charges and verify-only sessions. It needs a reporting key with the transactions:read scope.

reporting, err := dominaite.New(reportingKeyID, reportingSecret, dominaite.WithBaseURL(baseURL))
params := dominaite.ListTransactionsParams{
	From: "2026-10-01T00:00:00Z", // inclusive
	To:   "2026-11-01T00:00:00Z", // exclusive, at most 31 days after From
}
for {
	page, err := reporting.ListTransactions(ctx, params)
	if err != nil {
		return err
	}
	for _, tx := range page.Items {
		record(tx) // tx.TransactionID, tx.Type, tx.Status, tx.Amount, tx.RefundedAmount, ...
	}
	if page.NextCursor == "" {
		break
	}
	params.Cursor = page.NextCursor
}
  • The window is [From, To) on CreatedAt, in ISO 8601 UTC. Use the Z form: t.UTC().Format(time.RFC3339).
  • The rows come oldest first. Send NextCursor back as Cursor, with the same From, To, SettledFrom, SettledTo and Status, until it is empty. Paging never repeats or skips a row.
  • Limit sets the page size, 1 to 100. 0 keeps the API default of 50. Status keeps only the payments that are now in that status.
  • A row has its status at the time of the read. For a statement, end the window a few minutes in the past, so that payments still being written are not missed. Read the window again later to pick up changes such as refunds.
  • A refund is not a row of its own: it shows on its payment as RefundedAmount and Status.
  • Type is one of TransactionTypes. A verification row saved a card and is not a purchase, so leave it out of revenue.
  • Fee, NetAmount, SettledAt and PayoutID are empty until the acquirer settles the payment, usually the next business day. Fee and NetAmount can still change after that.
  • NetAmount is Amount minus the fee of this payment only, not the deductions on the payout.
  • To get one settlement day, set SettledFrom to that day and SettledTo to the next day ("2026-10-13", "2026-10-14"). Keep From and To wide enough to cover when those payments were created, for example the last 31 days.
  • The SDK signs the path only. The query string is not part of the signature.

A window, settlement window, status, limit or cursor that the API refuses is a 400 *APIError with ErrorCode VALIDATION_ERROR. Raw carries one error.validationErrors entry for each field.

Fields the SDK does not model yet

Every request struct with a body has an Extra map: CreateCheckoutSessionParams, ChargePaymentMethodParams, CreateRefundParams, CreatePlanParams, UpdatePlanParams, CreateAgreementParams and CreateWebhookEndpointParams. The SDK merges its keys into the JSON body and signs the result. A key in Extra replaces the modelled field of the same name. Use it for a field the API added after this SDK version; you do not need a new SDK release for it.

params.Extra = map[string]any{"someNewField": "value"}

Every response type has a Raw field with the unparsed JSON, for fields the SDK does not model yet. Status values, decline classes and event types are plain strings: a value this SDK does not know is never an error.

Errors

Every error the SDK returns satisfies the dominaite.Error interface and matches errors.Is(err, dominaite.ErrDominaite), so one catch-all works:

var sdkErr dominaite.Error
if errors.As(err, &sdkErr) { ... }

For the specific kind, use errors.As with the concrete pointer type:

Error When What to do
*RefusalError The API answered with success: false. ErrorCode carries the reason. Branch on ErrorCode. Do not blind-retry.
*ChargeError ChargePaymentMethod answered 404, 409, 422, 502 or 503 with a charge code; ErrorCode, HTTPStatus, and Charge/TransactionID when the gateway attached the charge row. Branch on ErrorCode; see Stored payment methods. CHARGE_OUTCOME_UNKNOWN: poll TransactionID, never retry under a new key.
*RevokeError RevokePaymentMethod answered 502 (UPSTREAM_CONTRACT_ERROR) or 503 (MERCHANT_API_UNAVAILABLE). Nothing changed. 503: retry later. 502: contact support with the id.
*RefundError CreateRefund or GetRefund answered 400, 404, 409 or 422 with a code; ErrorCode, HTTPStatus and Retryable. A failed refund is not an error: it is a Refund with Status failed. Branch on ErrorCode; see Refunds. Retry with the same key only when Retryable.
*AuthError 401 or 403, on HTTPStatus. 401: ErrorCode is INVALID_API_KEY, INVALID_SIGNATURE, TIMESTAMP_OUT_OF_RANGE, or IP_NOT_ALLOWED. 403: the request was signed correctly, but the key may not do this. ErrorCode is INSUFFICIENT_SCOPE (the key does not hold the scope of the route) or VERIFY_ONLY_NOT_ENABLED. 401: fix the key id, secret, server clock, or allowlist. 403 INSUFFICIENT_SCOPE: use a key that holds the scope. 403 VERIFY_ONLY_NOT_ENABLED: we switch it on for your account. Never retry-loop.
*RateLimitError 429. ErrorCode is RATE_LIMIT_EXCEEDED, or on a charge CHARGE_VELOCITY_EXCEEDED or CHARGE_DECLINE_RATE_EXCEEDED. RetryAfterSeconds carries the Retry-After header when the API sent one as integer seconds; HasRetryAfter tells "wait 0s" apart from "the API did not say". Raw is the whole envelope. Back off, then reschedule with the same idempotency key. The SDK never retries this for you.
*TransportError Network failure, timeout, or a 5xx without a code of the route. Wraps the cause, reachable with errors.Unwrap. HTTPStatus is the 5xx (0 when no response arrived), ErrorCode its code when it had one (MERCHANT_API_UNAVAILABLE, INTERNAL_ERROR), Raw its envelope. A 5xx classifies on the status, so an HTML or empty error page from an overloaded edge is still retryable. The client already retried it. Retry later with the same idempotency key.
*APIError Any other rejecting or unexpected response; HTTPStatus and ErrorCode carry the details. Raw is the whole envelope of a rejection, with error.params and error.validationErrors. Inspect. A storefront refusal lands here (see Storefront errors). A 3xx means a proxy or a wrong base URL answered with a redirect - the SDK never follows one. A replayed idempotency key does not land here; it comes back as a *RefusalError.
*ValidationError Bad arguments (non-positive amount outside a verify-only session, missing field or idempotency key, malformed key id). Fix the call; nothing was sent.
*WebhookVerificationError VerifyWebhook rejected an inbound delivery; Reason carries which check failed. Answer 400 with no detail. See Webhooks.

Every error built from a response carries RequestID: the Dominaite-Request-Id header, or metadata.requestId in the body when the header is absent. Results carry it too, on RequestID. Quote it when you ask support about a call. It is empty when no response arrived, and on a *ValidationError or a *WebhookVerificationError.

var chargeErr *dominaite.ChargeError
if errors.As(err, &chargeErr) {
	log.Printf("charge refused: %s (request %s)", chargeErr.ErrorCode, chargeErr.RequestID)
}

The code lists are exported for each route: ChargeErrorCodes, SessionRefusalErrorCodes, RecurringErrorCodes, WebhookErrorCodes, and for each group of the gateway's contract, AuthenticationErrorCodes, AuthorizationErrorCodes, IdempotencyErrorCodes, StorefrontErrorCodes, TransientErrorCodes and ValidationErrorCodes.

Refusal codes on RefusalError.ErrorCode, exported as ErrorCode* constants:

  • PAYMENT_PROCESSING_UNAVAILABLE (ErrorCodePaymentProcessingUnavailable) - card payments are off right now; retry later with the same key.
  • DUPLICATE_REQUEST (ErrorCodeDuplicateRequest) - a session for this idempotency key exists but cannot be handed back yet, or expired within the last few minutes. Re-POST the same key shortly, never a fresh one.
  • ALREADY_PROCESSED (ErrorCodeAlreadyProcessed) - this idempotency key's payment already completed.
  • PRIOR_ATTEMPT_FAILED (ErrorCodePriorAttemptFailed) - a prior attempt with this key failed terminally; use a fresh key.
  • IDEMPOTENCY_KEY_REUSED (ErrorCodeIdempotencyKeyReused) - same key sent with a different amount, currency or SaveCard; a re-priced order needs a new key.
  • STOREFRONT_MISMATCH (ErrorCodeStorefrontMismatch) - the key was first used for a different storefront.
Storefront errors

When a merchant runs more than one website, each session is filed under a storefront (an online location). A storefront refusal comes back as an *APIError with HTTPStatus and ErrorCode, nothing was minted, and a retry will not change it, so the client returns it at once:

ErrorCode HTTP Meaning
STOREFRONT_NOT_WHITELISTED (ErrorCodeStorefrontNotWhitelisted) 409 The site's domain is not whitelisted with the payment provider yet. Contact Dominaite support to finish it; until then this site cannot take payments.
STOREFRONT_INACTIVE (ErrorCodeStorefrontInactive) 409 The storefront was deactivated or deleted.
STOREFRONT_MISMATCH (ErrorCodeStorefrontMismatch) 400 The API key is bound to one storefront and the request named another. (On a replay of an existing key this is a *RefusalError instead.)
var apiErr *dominaite.APIError
if errors.As(err, &apiErr) {
	switch apiErr.ErrorCode {
	case dominaite.ErrorCodeStorefrontNotWhitelisted, dominaite.ErrorCodeStorefrontInactive:
		// Show "checkout unavailable" and alert your ops; do not retry in a loop.
	case dominaite.ErrorCodeStorefrontMismatch:
		// Configuration bug: this key belongs to another site.
	}
}
Recovering from a replay refusal

When your idempotency key collides with an earlier attempt, the refusal names the transaction it collided with, so you can reconcile instead of minting a second payment:

session, err := client.CreateCheckoutSession(ctx, params)

var refusal *dominaite.RefusalError
if errors.As(err, &refusal) && refusal.TransactionID != "" {
	status, err := client.GetStatus(ctx, refusal.TransactionID)
	// Now you know what the earlier attempt actually did.
}

RefusalError.TransactionID is empty when the API did not name one (a concurrent-race DUPLICATE_REQUEST knows the key is taken but not yet by which row), so check it before use. The full refusal payload is on RefusalError.Raw.

One replay is not a refusal at all. A session that expired unpaid is superseded: from a few minutes past its expiry, re-POSTing the same key returns an ordinary success with a fresh session (a new TransactionID, same key), so a customer who comes back late just pays. Keep the order-derived key for the life of the order to keep that path open. The band is not endless - once the platform has independently closed the attempt (about an hour past expiry), the replay answers PRIOR_ATTEMPT_FAILED and the key is spent; reconcile through GetStatus and use a fresh key.

Reading the API contract

GetIntegrationContract reads the API's machine-readable contract: its routes, fields, error codes and vocabularies, as the API serves them now. The route is public, so it needs no scope. Version, Statuses and WebhookEventCatalog are typed; everything else is on Raw.

contract, err := client.GetIntegrationContract(ctx)
if err != nil {
	return err
}
log.Printf("contract version %s", contract.Version) // e.g. 2026-09-16

Verifying your signing

Run go test ./... before you touch the live API. The SDK signs for you, but the recipe is pinned by an offline known-answer vector shared with the gateway and the dashboard, and the suite reproduces it byte-for-byte. If that test fails, nothing else matters.

If you ever hand-roll the signing (or debug an INVALID_SIGNATURE), Sign is exported:

dominaite.Sign(dominaite.SignInput{
	Secret:         "dms_...",
	Timestamp:      "1755302400",                           // unix SECONDS
	Method:         "POST",
	Path:           "/merchant-api/checkout/sessions",      // path only, no host
	IdempotencyKey: "00000000-0000-4000-8000-000000000001", // "" for GET
	Body:           `{"amount":2500,"currency":"EUR","orderReference":"order-1042"}`, // "" for GET
})
// "8f5fba0b29a8eea81b76a0e6d7119e79ec68f586910f77713b045652e5ce9b74"

Printing a SignInput or a Client itself is safe: both redact the secret under %v, %+v, %#v and %s, so a debug log shows dms_***redacted*** and every other field. SignInput also carries json:"-" on Secret, so JSON encoders drop it.

Two cases the redaction cannot reach, because neither goes through the String method:

  • A SignInput or Client sitting in an unexported field of your own struct, printed by printing that outer struct. fmt cannot call methods on values it reaches by reflection, so it dumps the fields raw: a by-value field leaks under %v, %+v and %s, and even the usual client *dominaite.Client field leaks under %s.
  • Any encoder that walks the struct instead of printing it. SignInput is covered by the tag; anything else you build around your secret is not.

Keep the secret out of structured logs entirely rather than relying on redaction.

The signed payload is five lines: "{timestamp}\n{METHOD}\n{path}\n{idempotencyKey}\n{sha256hex(body)}", signed as lowercase hex HMAC-SHA256 with your secret, UTF-8 throughout. GET signs an empty idempotency key and an empty body, and sends no Idempotency-Key header.

Versioning and support

The SDK is 0.x on purpose. A minor release (for example 0.6 to 0.7) can contain a breaking change. A patch release (0.6.0 to 0.6.1) does not. CHANGELOG.md lists every breaking change and what to do about it.

go.mod records the exact version you build with, so nothing moves until you ask. Upgrade on purpose with go get github.com/dominaite/merchant-sdk-go@v0.7.0, and read CHANGELOG.md first. go get -u can take you to a new minor version, which can break your build.

The SDK needs Go 1.21 or later. CI tests it on Go 1.21 and on the current stable Go.

When the API marks your SDK version as deprecated, it sends a Dominaite-Sdk-Notice header. The client logs it as one slog.LevelWarn warning for each distinct notice (see Logging and warnings). The warning names the version to upgrade to and the sunset date when the notice carries them. Send the client's warnings to a log you read with WithLogger, so you see the notice before the sunset date. A response without the header changes nothing.

Documentation

Overview

Package dominaite is the server-side Go client for the Dominaite merchant API.

One call from your backend opens a hosted checkout session; a two-line script tag renders the payment widget on your page. Card details go straight from your customer's browser into the widget. They never touch your server or this SDK, which keeps your PCI scope minimal (SAQ A).

Keep your API secret on the server. Never ship it to a browser, never commit it, never log it.

client, err := dominaite.New(
	os.Getenv("DOMINAITE_KEY_ID"),
	os.Getenv("DOMINAITE_SECRET"),
	dominaite.WithBaseURL(dominaite.SandboxBaseURL), // required
)
if err != nil {
	return err
}
key, err := dominaite.OrderIdempotencyKey("checkout", "order-1042", 2500, "EUR")
if err != nil {
	return err
}
session, err := client.CreateCheckoutSession(ctx, dominaite.CreateCheckoutSessionParams{
	Amount:         2500, // minor units: 25.00 EUR
	Currency:       "EUR",
	OrderReference: "order-1042",
	IdempotencyKey: key, // "checkout-order-1042-2500-EUR"
})

Hand session.CashierKey and session.CashierToken to the embed snippet.

Index

Examples

Constants

View Source
const (
	AgreementStatusPending   = "pending"
	AgreementStatusActive    = "active"
	AgreementStatusPastDue   = "past_due"
	AgreementStatusCancelled = "cancelled"
	AgreementStatusCompleted = "completed"
	AgreementStatusFailed    = "failed"
)

Agreement status values, in the gateway's own order. pending: created, not paid yet. active: with scheduled charges on, the platform charges the card each period. past_due: the platform gave up on the current period on this card (PastDueReason says why); it stays past_due until the period is paid on another card, or until you cancel it. cancelled, completed and failed are final.

View Source
const (
	PastDueReasonCardExpired      = "card_expired"
	PastDueReasonCardRevoked      = "card_revoked"
	PastDueReasonHardDecline      = "hard_decline"
	PastDueReasonDunningExhausted = "dunning_exhausted"
	PastDueReasonSCARequired      = "sca_required"
	PastDueReasonUnprocessable    = "unprocessable"
)

Why an agreement is past_due, on Agreement.PastDueReason, in the gateway's own order. PastDueReasonSCARequired: the issuer asked for customer authentication, which no unattended retry can pass.

View Source
const (
	// SandboxBaseURL is the sandbox merchant API. Use it with your sandbox keys.
	SandboxBaseURL = "https://api.staging.dominaite.com/payments"

	// ProductionBaseURL is the production merchant API. Use it with your live keys.
	ProductionBaseURL = "https://api.dominaite.com/payments"

	// SessionsPath is the canonical path that gets signed. POST creates a
	// session; GET SessionsPath + "/{transactionId}" reads its status.
	SessionsPath = "/merchant-api/checkout/sessions"

	// PaymentMethodsPath is the canonical path of stored payment methods. POST
	// PaymentMethodsPath + "/{paymentMethodId}/charges" charges one; DELETE
	// PaymentMethodsPath + "/{paymentMethodId}" revokes it.
	PaymentMethodsPath = "/merchant-api/payment-methods"

	// PaymentsPath is the canonical path of payments. POST PaymentsPath +
	// "/{transactionId}/refunds" refunds one, GET on the same path lists its
	// refunds, and GET PaymentsPath + "/{transactionId}/refunds/{refundId}"
	// reads a refund back.
	PaymentsPath = "/merchant-api/payments"

	// PlansPath is the canonical path of subscription plans. POST creates a
	// plan, GET lists them; GET, PATCH and DELETE PlansPath + "/{planId}"
	// read, change and archive one.
	PlansPath = "/merchant-api/plans"

	// AgreementsPath is the canonical path of subscription agreements. POST
	// creates an agreement; GET and DELETE AgreementsPath + "/{agreementId}"
	// read and cancel one.
	AgreementsPath = "/merchant-api/agreements"

	// WebhooksPath is the canonical path of your webhook endpoints. POST
	// registers one, GET lists them, DELETE WebhooksPath + "/{webhookId}"
	// deletes one.
	WebhooksPath = "/merchant-api/webhooks"

	// TransactionsPath is the canonical path of your transaction list. GET
	// reads one page. The query string is never part of the signature.
	TransactionsPath = "/merchant-api/transactions"

	// PingPath is the credentials-and-clock smoke test. It creates nothing.
	PingPath = "/merchant-api/ping"

	// IntegrationContractPath is the API's machine-readable contract. GET
	// reads it. The route is public, so it needs no scope.
	IntegrationContractPath = "/merchant-api/integration/contract"

	// Version is this SDK's version, reported in the User-Agent.
	Version = "0.7.0"
)
View Source
const (
	// ErrorCodeStorefrontNotWhitelisted (HTTP 409): the storefront's domain is
	// not whitelisted with the payment provider yet, and the environment
	// requires it. Nothing was minted. Ask Dominaite support to finish the
	// domain whitelisting; retrying will not help until then.
	ErrorCodeStorefrontNotWhitelisted = "STOREFRONT_NOT_WHITELISTED"
	// ErrorCodeStorefrontInactive (HTTP 409): the storefront was deactivated
	// or deleted. Reactivate it in the dashboard or use another one.
	ErrorCodeStorefrontInactive = "STOREFRONT_INACTIVE"
	// ErrorCodeStorefrontMismatch (HTTP 400): the API key is bound to one
	// storefront and the request named another. A replay of a key minted for
	// a different storefront answers the same code as a *RefusalError.
	ErrorCodeStorefrontMismatch = "STOREFRONT_MISMATCH"

	// ErrorCodeAlreadyProcessed (refusal): the payment for this idempotency
	// key already moved money. Mark the order paid; the key is spent.
	ErrorCodeAlreadyProcessed = "ALREADY_PROCESSED"
	// ErrorCodePriorAttemptFailed (refusal): the attempt for this key ended
	// failed, cancelled or abandoned. The key is spent; try again under a
	// new one.
	ErrorCodePriorAttemptFailed = "PRIOR_ATTEMPT_FAILED"
	// ErrorCodeDuplicateRequest (refusal): a session for this key exists but
	// cannot be handed back yet. Re-POST the SAME key after about a second.
	ErrorCodeDuplicateRequest = "DUPLICATE_REQUEST"
	// ErrorCodePaymentProcessingUnavailable (refusal on session create; 503 on
	// a charge): card payments are off right now. Nothing was minted or
	// charged; retry later with the SAME key.
	ErrorCodePaymentProcessingUnavailable = "PAYMENT_PROCESSING_UNAVAILABLE"
	// ErrorCodeIdempotencyKeyReused (refusal): this key was first used for a
	// different amount, currency or card-saving choice. A re-priced order
	// needs a new key; OrderIdempotencyKey derives one.
	ErrorCodeIdempotencyKeyReused = "IDEMPOTENCY_KEY_REUSED"
)

Error codes the checkout session route answers with, for branching on the ErrorCode of a *RefusalError or an *APIError instead of on message text. The same strings can also arrive on a *ChargeError (see the ChargeError* constants for what they mean there).

The storefront codes mean the session was refused because of the online location (storefront) it would be filed under. They arrive as an *APIError with HTTPStatus set, not as a refusal, and a retry will not change them:

var apiErr *dominaite.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode == dominaite.ErrorCodeStorefrontNotWhitelisted {
	// the site's domain is not whitelisted with the payment provider yet
}
View Source
const (
	// ErrorCodeAgreementNotFound: no agreement with this id under your
	// account. The agr_ id is case-sensitive; send it exactly as it was
	// returned. The agreement routes answer the same code as a 404 *APIError.
	ErrorCodeAgreementNotFound = "AGREEMENT_NOT_FOUND"
	// ErrorCodeAgreementNotPending: the agreement is already active,
	// cancelled, completed or failed, so there is no first payment left to
	// take.
	ErrorCodeAgreementNotPending = "AGREEMENT_NOT_PENDING"
	// ErrorCodeAgreementAmountMismatch: the amount or the currency is not the
	// agreement's own. On a plan without a trial the session must charge
	// exactly the agreement's amount; the currency must always match.
	ErrorCodeAgreementAmountMismatch = "AGREEMENT_AMOUNT_MISMATCH"
	// ErrorCodeAgreementConsentRequired: neither the session nor the
	// agreement carries consent to a version of the platform text. Send
	// Consent, with all three fields, on the session.
	ErrorCodeAgreementConsentRequired = "AGREEMENT_CONSENT_REQUIRED"
	// ErrorCodeAgreementSessionOpen: a session for this agreement is still
	// open, and TransactionID names it. Mint a new session with a new key
	// only after that one has expired.
	ErrorCodeAgreementSessionOpen = "AGREEMENT_SESSION_OPEN"
	// ErrorCodeAgreementAlreadyPaid: the session of this agreement was paid,
	// and the agreement is still pending. Read the status of TransactionID.
	// If the agreement stays pending, contact support with both ids. Never
	// mint again: that would charge the customer twice.
	ErrorCodeAgreementAlreadyPaid = "AGREEMENT_ALREADY_PAID"
	// ErrorCodeAgreementVariableAmountUnsupported: the agreement has a
	// MaxAmountPerPeriod, and a session can take the first payment of a
	// fixed-amount agreement only.
	ErrorCodeAgreementVariableAmountUnsupported = "AGREEMENT_VARIABLE_AMOUNT_UNSUPPORTED"
	// ErrorCodeSubscriptionsNotEnabled: subscriptions are not switched on for
	// your account yet. Plans and agreements can already be created. Contact
	// support; do not retry on a schedule.
	ErrorCodeSubscriptionsNotEnabled = "SUBSCRIPTIONS_NOT_ENABLED"
)

Subscription session refusal codes, in the gateway's own order: the RefusalError.ErrorCode of a session created with AgreementID. They arrive as HTTP 200 with success false, like every session refusal. Nothing was created under any of them, and none changes on a retry with the same body.

View Source
const (
	// ErrorCodePlanNotFound (404): no plan with this id under your account.
	// The plan_ id is case-sensitive; send it exactly as it was returned.
	ErrorCodePlanNotFound = "PLAN_NOT_FOUND"
	// ErrorCodePlanNotActive (409, CreateAgreement): the plan is archived.
	ErrorCodePlanNotActive = "PLAN_NOT_ACTIVE"
	// ErrorCodeAgreementEnded (409, CancelAgreement): the agreement already
	// completed or failed, so there is nothing left to cancel.
	ErrorCodeAgreementEnded = "AGREEMENT_ENDED"
	// ErrorCodePaymentMethodNotFound (404, CreateAgreement): no stored
	// payment method with this id under your account.
	ErrorCodePaymentMethodNotFound = "PAYMENT_METHOD_NOT_FOUND"
	// ErrorCodePaymentMethodNotActive (409, CreateAgreement): the stored
	// payment method is revoked, expired or retired. Leave it out and let the
	// subscription session save a fresh card.
	ErrorCodePaymentMethodNotActive = "PAYMENT_METHOD_NOT_ACTIVE"
	// ErrorCodePaymentMethodCurrencyMismatch (422, CreateAgreement): the card
	// was saved from a payment in another currency than the plan's.
	ErrorCodePaymentMethodCurrencyMismatch = "PAYMENT_METHOD_CURRENCY_MISMATCH"
	// ErrorCodePaymentMethodCurrencyUnknown (422, CreateAgreement): the
	// currency of the card cannot be established, so it cannot back an
	// agreement. Leave it out and let the subscription session save a fresh
	// card.
	ErrorCodePaymentMethodCurrencyUnknown = "PAYMENT_METHOD_CURRENCY_UNKNOWN"
	// ErrorCodeMaxAmountBelowPlanAmount (422, CreateAgreement):
	// MaxAmountPerPeriod is below the plan's amount.
	ErrorCodeMaxAmountBelowPlanAmount = "MAX_AMOUNT_BELOW_PLAN_AMOUNT"
)

Codes of the plan and agreement routes. They arrive on APIError.ErrorCode, with the status on APIError.HTTPStatus. ErrorCodeAgreementNotFound (404), ErrorCodeAgreementConsentRequired (422: CreateAgreement names a stored payment method or a MaxAmountPerPeriod without Consent) and ErrorCodeIdempotencyKeyReused (422: the key was first used for a different plan or agreement) arrive there too. The list is additive: treat a 4xx code you do not know as not retryable. A 5xx is a *TransportError, with the code on TransportError.ErrorCode when there was one: nothing was created, so retry with the same key.

View Source
const (
	// ErrorCodeWebhookURLRejected (400): the URL is not https, is malformed,
	// does not resolve, or points at a private, loopback or link-local host.
	// The message says which.
	ErrorCodeWebhookURLRejected = "WEBHOOK_URL_REJECTED"
	// ErrorCodeWebhookLimitReached (409): you already have the maximum of 25
	// active endpoints. Delete one first.
	ErrorCodeWebhookLimitReached = "WEBHOOK_LIMIT_REACHED"
	// ErrorCodeWebhookAlreadyRegistered (409): this key registered an endpoint
	// more than a day ago, and that endpoint still exists. Its id is at
	// error.params.webhookId in APIError.Raw. Use a fresh key for a new
	// endpoint.
	ErrorCodeWebhookAlreadyRegistered = "WEBHOOK_ALREADY_REGISTERED"
	// ErrorCodeWebhookNotFound (404, DeleteWebhookEndpoint): no endpoint with
	// this id under your account. After a lost answer, treat it as done.
	ErrorCodeWebhookNotFound = "WEBHOOK_NOT_FOUND"
)

Codes of the webhook endpoint routes. They arrive on APIError.ErrorCode, with the status on APIError.HTTPStatus. ErrorCodeIdempotencyKeyReused (422: the key was first used for a different URL or events) and ErrorCodeDuplicateRequest (409: a request with this key is in progress; retry with the same key after a second) arrive there too. Nothing was registered under any of them.

View Source
const (
	// ErrorCodeInvalidAPIKey: the key id is unknown, malformed or revoked, or
	// it belongs to another environment.
	ErrorCodeInvalidAPIKey = "INVALID_API_KEY"
	// ErrorCodeInvalidSignature: the signature does not match. Check the
	// secret and that the signed body is the body sent.
	ErrorCodeInvalidSignature = "INVALID_SIGNATURE"
	// ErrorCodeTimestampOutOfRange: X-Timestamp is more than 300 seconds
	// from the server clock. Sync this server's clock.
	ErrorCodeTimestampOutOfRange = "TIMESTAMP_OUT_OF_RANGE"
	// ErrorCodeIPNotAllowed: the caller's IP address is not on the key's
	// allowlist.
	ErrorCodeIPNotAllowed = "IP_NOT_ALLOWED"
)

Authentication codes (HTTP 401), on AuthError.ErrorCode. None is retryable: fix the key id, the secret, the server clock or the allowlist.

View Source
const (
	// ErrorCodeMerchantAPIUnavailable (HTTP 503): the API or a provider
	// behind it is unavailable. A *TransportError carries it on ErrorCode; a
	// revoke carries it on a *RevokeError.
	ErrorCodeMerchantAPIUnavailable = "MERCHANT_API_UNAVAILABLE"
	// ErrorCodeInternalError (HTTP 500): the API failed before it could
	// answer. A *TransportError carries it on ErrorCode.
	ErrorCodeInternalError = "INTERNAL_ERROR"
)

Transient codes: nothing was created and a retry with the SAME key can succeed.

View Source
const (
	ErrorCodeRequired         = "REQUIRED"
	ErrorCodeMinValue         = "MIN_VALUE"
	ErrorCodeMaxLength        = "MAX_LENGTH"
	ErrorCodeInvalidFormat    = "INVALID_FORMAT"
	ErrorCodeInvalidSelection = "INVALID_SELECTION"
	ErrorCodeDateInFuture     = "DATE_IN_FUTURE"
	ErrorCodeInvalidEmail     = "INVALID_EMAIL"
	ErrorCodeInvalidCurrency  = "INVALID_CURRENCY"
)

Field codes of a validation failure: the code of each entry in error.validationErrors on APIError.Raw. A plan create also answers ErrorCodeInvalidCurrency and ErrorCodeInvalidSelection as the envelope code.

View Source
const (
	// ErrorCodeRateLimitExceeded: the key or the IP address sent more
	// requests than it may. Retry-After says how long to wait. The API sends
	// no Retry-After when it locks an address out after repeated failed
	// sign-ins; fix the credentials before you retry.
	ErrorCodeRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
	// ErrorCodeChargeVelocityExceeded (ChargePaymentMethod): a volume limit
	// refused the charge before anything was placed. error.params.window and
	// error.params.limit on Raw say which limit. A charge that keeps hitting
	// it is a pattern to look into, not to retry around.
	ErrorCodeChargeVelocityExceeded = "CHARGE_VELOCITY_EXCEEDED"
	// ErrorCodeChargeDeclineRateExceeded (ChargePaymentMethod): the key
	// collected too many declined charges in a short window, so its charge
	// route is locked for a while. The other routes keep working. If this is
	// a genuine billing run with many declines, contact support.
	ErrorCodeChargeDeclineRateExceeded = "CHARGE_DECLINE_RATE_EXCEEDED"
)

Rate limit codes, on RateLimitError.ErrorCode. Nothing was created or charged under any of them; retry with the SAME idempotency key once the wait is over.

View Source
const (
	// ChargeErrorPaymentMethodNotActive (409): the method is revoked, expired
	// or retired; ask the customer for another card via a hosted session with
	// SaveCard.
	ChargeErrorPaymentMethodNotActive = "PAYMENT_METHOD_NOT_ACTIVE"
	// ChargeErrorDuplicateRequest (409): a request with this key is still in
	// flight; retry with the SAME key in a moment.
	ChargeErrorDuplicateRequest = "DUPLICATE_REQUEST"
	// ChargeErrorIdempotencyKeyReused (422): same key, different body or
	// method; a bug on your side.
	ChargeErrorIdempotencyKeyReused = "IDEMPOTENCY_KEY_REUSED"
	// ChargeErrorOutcomeUnknown (502): the provider gave no verdict and the
	// charge MAY have happened. Charge is set: poll
	// GetStatus(Charge.TransactionID) or wait for the webhook. Never retry
	// under a new key.
	ChargeErrorOutcomeUnknown = "CHARGE_OUTCOME_UNKNOWN"
	// ChargeErrorFailed (502): nothing was charged. Charge is set when a row
	// exists (its DeclineClass and DeclineCode are empty), nil when the
	// provider refused before one.
	ChargeErrorFailed = "CHARGE_FAILED"
	// ChargeErrorChargesDisabled (503): nothing was charged. The API answers
	// it until we switch saved-card charges on for your account, and when we
	// pause charges for a short time. The code is the same in both cases. If
	// we never switched saved-card charges on for your account, a retry does
	// not help: ask us. If they are on, retry later with the SAME key.
	ChargeErrorChargesDisabled = "PAYMENT_METHOD_CHARGES_DISABLED"
	// ChargeErrorProcessingUnavailable (503): nothing was charged; retry later
	// with the SAME key.
	ChargeErrorProcessingUnavailable = "PAYMENT_PROCESSING_UNAVAILABLE"

	// ChargeErrorPaymentMethodNotFound (404): no stored payment method with
	// this id under your account.
	ChargeErrorPaymentMethodNotFound = "PAYMENT_METHOD_NOT_FOUND"
	// ChargeErrorInProgress (409): another charge of this payment method is
	// still running. Retry with the SAME key in a moment.
	ChargeErrorInProgress = "CHARGE_IN_PROGRESS"
	// ChargeErrorCurrencyMismatch (422): the currency is not the currency the
	// card was saved in.
	ChargeErrorCurrencyMismatch = "CHARGE_CURRENCY_MISMATCH"
	// ChargeErrorAgreementNotFound (404): no agreement with the AgreementID
	// under your account.
	ChargeErrorAgreementNotFound = "AGREEMENT_NOT_FOUND"
	// ChargeErrorAgreementMethodMismatch (422): the payment method is not the
	// card of the agreement.
	ChargeErrorAgreementMethodMismatch = "AGREEMENT_METHOD_MISMATCH"
	// ChargeErrorAgreementConsentMissing (422): the agreement has no recorded
	// consent, so it cannot be charged.
	ChargeErrorAgreementConsentMissing = "AGREEMENT_CONSENT_MISSING"
	// ChargeErrorAgreementCurrencyMismatch (422): the currency is not the
	// agreement's currency.
	ChargeErrorAgreementCurrencyMismatch = "AGREEMENT_CURRENCY_MISMATCH"
	// ChargeErrorAgreementAmountMismatch (422): the amount is not the amount
	// of a fixed agreement.
	ChargeErrorAgreementAmountMismatch = "AGREEMENT_AMOUNT_MISMATCH"
	// ChargeErrorAmountAboveAgreement (422): the amount is above the
	// MaxAmountPerPeriod of a variable agreement.
	ChargeErrorAmountAboveAgreement = "CHARGE_AMOUNT_ABOVE_AGREEMENT"
	// ChargeErrorAgreementPeriodAlreadyCharged (422): this period of the
	// agreement was already charged.
	ChargeErrorAgreementPeriodAlreadyCharged = "AGREEMENT_PERIOD_ALREADY_CHARGED"
	// ChargeErrorAgreementEnded (422): the agreement is cancelled, completed or
	// failed.
	ChargeErrorAgreementEnded = "AGREEMENT_ENDED"
	// ChargeErrorAgreementNotStarted (422): the agreement is not active yet.
	ChargeErrorAgreementNotStarted = "AGREEMENT_NOT_STARTED"
	// ChargeErrorAmountAboveCap (422): the amount is above the per-charge cap
	// of your account. error.params.amount and error.params.cap on Raw say
	// both values.
	ChargeErrorAmountAboveCap = "CHARGE_AMOUNT_ABOVE_CAP"
)

Charge error codes, in the gateway's own order: the codes ChargePaymentMethod returns as a *ChargeError. CHARGE_DECLINED (HTTP 402) is deliberately not one of them: a decline is a charge result with Status ChargeStatusFailed, not an error.

View Source
const (
	// RevokeErrorUpstreamContract (502): the provider refused the deletion for
	// a reason a retry will not fix; contact support with the payment method id.
	RevokeErrorUpstreamContract = "UPSTREAM_CONTRACT_ERROR"
	// RevokeErrorMerchantAPIUnavailable (503): the provider is unavailable or
	// throttling; retry later.
	RevokeErrorMerchantAPIUnavailable = "MERCHANT_API_UNAVAILABLE"
)

Revoke error codes, in the gateway's own order: the codes RevokePaymentMethod returns as a *RevokeError. Nothing changed under either.

View Source
const (
	// RefundErrorPaymentNotFound (404): no card-not-present payment with this
	// id under your account. Not retryable.
	RefundErrorPaymentNotFound = "PAYMENT_NOT_FOUND"
	// RefundErrorRefundNotFound (404, GetRefund only): no refund with this id
	// on this payment. Right after CreateRefund the refund may not be picked
	// up yet: poll again for up to 60 seconds, after that the id is unknown.
	RefundErrorRefundNotFound = "REFUND_NOT_FOUND"
	// RefundErrorPaymentNotRefundable (422): the payment is not paid, already
	// fully refunded, or everything left on it is already being refunded.
	// Nothing was queued and the key is not burnt.
	RefundErrorPaymentNotRefundable = "PAYMENT_NOT_REFUNDABLE"
	// RefundErrorAmountExceeded (422): the amount is more than what is left to
	// refund, counting refunds still in progress; the message names the
	// amount left. Nothing was queued and the key is not burnt.
	RefundErrorAmountExceeded = "REFUND_AMOUNT_EXCEEDED"
	// RefundErrorIdempotencyKeyReused (422): the key was first used for a
	// different amount, reason or payment. Use a fresh key for a genuinely
	// new refund.
	RefundErrorIdempotencyKeyReused = "IDEMPOTENCY_KEY_REUSED"
	// RefundErrorDuplicateRequest (409): a request with this key is being
	// processed right now. Retry with the SAME key after a second, for up to
	// 120 seconds.
	RefundErrorDuplicateRequest = "DUPLICATE_REQUEST"
	// RefundErrorIdempotencyKeyRequired (400): the Idempotency-Key header was
	// missing or too long. The SDK refuses such a key before sending, so this
	// only arrives if something between you and the API dropped the header.
	RefundErrorIdempotencyKeyRequired = "IDEMPOTENCY_KEY_REQUIRED"
)

Refund error codes, in the gateway's own order: the codes CreateRefund and GetRefund return as a *RefundError.

View Source
const (
	// RefundFailureAmountExceeded: by the time the refund ran, less was left
	// to refund than it asked for.
	RefundFailureAmountExceeded = "REFUND_AMOUNT_EXCEEDED"
	// RefundFailurePaymentNotRefundable: by the time the refund ran, the
	// payment could no longer be refunded.
	RefundFailurePaymentNotRefundable = "PAYMENT_NOT_REFUNDABLE"
	// RefundFailureFailed: the refund could not be completed. Retry with a new
	// idempotency key if it is still wanted.
	RefundFailureFailed = "REFUND_FAILED"
)

Refund failure codes, in the gateway's own order: the values of Refund.FailureCode on a failed refund. They are not errors: CreateRefund and GetRefund return the refund with Status RefundStatusFailed. Treat a value you do not recognise as RefundFailureFailed.

View Source
const (
	// WebhookReasonMalformedSignature means the signature header was missing,
	// empty, or not in the "t=...,v1=..." shape (no t, no v1, or a v1 that is
	// not hex). Nothing could be checked.
	WebhookReasonMalformedSignature = "MALFORMED_SIGNATURE"
	// WebhookReasonSignatureMismatch means the MAC did not match. Either the
	// secret is wrong or the payload is not the bytes that were signed. Treat
	// the request as hostile.
	WebhookReasonSignatureMismatch = "SIGNATURE_MISMATCH"
	// WebhookReasonTimestampOutOfTolerance means the MAC was valid but the
	// timestamp is too far from now: a replay of a genuine old delivery, or
	// your server clock has drifted. Check NTP before widening the tolerance.
	WebhookReasonTimestampOutOfTolerance = "TIMESTAMP_OUT_OF_TOLERANCE"
	// WebhookReasonMalformedPayload means the signature was good but the body
	// was not valid JSON. Surprising - the bytes are authentic - so log it
	// rather than silently dropping it.
	WebhookReasonMalformedPayload = "MALFORMED_PAYLOAD"
	// WebhookReasonInvalidTolerance means the caller passed a negative
	// tolerance to WithWebhookTolerance. That is your bug, not the sender's:
	// nothing about the delivery was checked. Use 0 to require an exact
	// timestamp, or a positive window.
	WebhookReasonInvalidTolerance = "INVALID_TOLERANCE"
)

Reasons reported on WebhookVerificationError.Reason.

View Source
const (
	// NoticeHeader is the response header in which the API tells an SDK that
	// it is deprecated: deprecated; current="0.7.0"; sunset="2027-01-31"; info="https://...".
	NoticeHeader = "Dominaite-Sdk-Notice"

	// RequestIDHeader names the API's id for one call. Quote it to support.
	RequestIDHeader = "Dominaite-Request-Id"
)
View Source
const (
	PlanStatusActive   = "active"
	PlanStatusArchived = "archived"
)

Plan status values, in the gateway's own order. Only an active plan accepts new agreements.

View Source
const (
	IntervalUnitDay   = "day"
	IntervalUnitWeek  = "week"
	IntervalUnitMonth = "month"
	IntervalUnitYear  = "year"
)

Interval units of a plan's billing period. The interval is at most one year: 365 days, 52 weeks, 12 months or 1 year.

View Source
const (
	TransactionTypePayment      = "payment"
	TransactionTypeCharge       = "charge"
	TransactionTypeVerification = "verification"
)

Transaction types on Transaction.Type, in the gateway's own order. TransactionTypePayment: the payer paid on the checkout. TransactionTypeCharge: a saved card was charged, by you or by the subscription scheduler. TransactionTypeVerification: a verify-only session saved a card. It is not a purchase, so leave it out of revenue.

View Source
const (
	StatusPending           = "pending"
	StatusProcessing        = "processing"
	StatusSucceeded         = "succeeded"
	StatusFailed            = "failed"
	StatusRefunded          = "refunded"
	StatusPartiallyRefunded = "partially_refunded"
	StatusCancelled         = "cancelled"
	StatusDisputed          = "disputed"
	StatusRequiresCapture   = "requires_capture"
	StatusAbandoned         = "abandoned"
)

Transaction status values returned by GetStatus.

View Source
const (
	PaymentMethodCard         = "card"
	PaymentMethodWallet       = "wallet"
	PaymentMethodBankTransfer = "bank_transfer"
	PaymentMethodSepa         = "sepa"
)

Payment method categories reported in CheckoutStatus.PaymentMethod.

View Source
const (
	WalletTypeApplePay   = "apple_pay"
	WalletTypeGooglePay  = "google_pay"
	WalletTypeSamsungPay = "samsung_pay"
)

Wallets reported in CheckoutStatus.WalletType.

View Source
const (
	StoredPaymentMethodStatusActive  = "active"
	StoredPaymentMethodStatusRevoked = "revoked"
	StoredPaymentMethodStatusExpired = "expired"
	StoredPaymentMethodStatusRetired = "retired"
)

Stored payment method status values, in the gateway's own order.

Only active methods can be charged. StoredPaymentMethodStatusRevoked is what Client.RevokePaymentMethod leaves behind; StoredPaymentMethodStatusExpired means the card's expiry date has passed; StoredPaymentMethodStatusRetired means the platform stopped the card on its own (RetiredReason says why) and it never becomes active again, so ask the customer to save a card again.

View Source
const (
	RetiredReasonHardDecline        = "hard_decline"
	RetiredReasonChargeback         = "chargeback"
	RetiredReasonSourceSaleReversed = "source_sale_reversed"
)

Why the platform retired a stored payment method, in the gateway's own order.

RetiredReasonHardDecline: a charge on the card was declined as final. RetiredReasonChargeback: a charge on it was disputed. RetiredReasonSourceSaleReversed: the payment that saved it was fully refunded or disputed.

View Source
const (
	ChargeStatusSucceeded = "succeeded"
	ChargeStatusFailed    = "failed"
	ChargeStatusPending   = "pending"
	ChargeStatusCancelled = "cancelled"
)

Charge status values returned by ChargePaymentMethod, in the gateway's own order. ChargeStatusSucceeded: the money moved. ChargeStatusFailed: it did not; DeclineClass says why, when the class is known. ChargeStatusPending is not terminal: keep polling GetStatus on the charge's TransactionID. ChargeStatusCancelled: an authorization voided before capture, no money moved.

View Source
const (
	DeclineClassHard            = "hard"
	DeclineClassSoftFunds       = "soft_funds"
	DeclineClassSoftSCARequired = "soft_sca_required"
	DeclineClassSoftOther       = "soft_other"
)

Decline classes, coarse enough to act on without reading the issuer's code:

  • DeclineClassHard: do not retry this card, ask the customer for another one.
  • DeclineClassSoftFunds: insufficient funds; retry later (after the customer's payday, not in a loop).
  • DeclineClassSoftSCARequired: the issuer wants the customer present; send them through a hosted checkout session with SaveCard instead of charging off-session again.
  • DeclineClassSoftOther: a transient issuer or network condition; one retry later is reasonable.
View Source
const (
	RefundStatusPending    = "pending"
	RefundStatusProcessing = "processing"
	RefundStatusSucceeded  = "succeeded"
	RefundStatusFailed     = "failed"
)

Refund status values, in the gateway's own order. RefundStatusPending: queued. RefundStatusProcessing: with the payment provider now. RefundStatusSucceeded: the money was returned to the payer. RefundStatusFailed: the refund did not happen, read FailureCode; final for that idempotency key, so a new attempt needs a new key.

View Source
const (
	EventPaymentSucceeded       = "payment.succeeded"
	EventPaymentFailed          = "payment.failed"
	EventPaymentRequiresCapture = "payment.requires_capture"
	EventPaymentCancelled       = "payment.cancelled"
	EventPaymentAbandoned       = "payment.abandoned"
	EventPaymentRefunded        = "payment.refunded"
	EventPaymentDisputed        = "payment.disputed"
)

Webhook event types of payments: the contract's webhookEventCatalog. With the agreement, charge and verification types below, they are every type that endpoint registration accepts. It refuses any other type, and the match is case-sensitive. The list can grow: treat a type you do not know as a no-op.

EventPaymentSucceeded is the ONLY signal that means money is in hand. The in-flight states (pending, processing) are deliberately not webhooked; poll GetStatus if you want to drive UX off them.

View Source
const (
	EventAgreementActivated = "agreement.activated"
	EventAgreementPastDue   = "agreement.past_due"
	EventAgreementCancelled = "agreement.cancelled"
	EventChargeSucceeded    = "charge.succeeded"
	EventChargeFailed       = "charge.failed"
	EventChargeRetrying     = "charge.retrying"
)

Webhook event types of agreements and saved-card charges. They carry Data.Sequence: order them by it, never by CreatedAt.

EventAgreementPastDue: the platform gave up on the current period on the agreement's card. Nothing cancels the agreement automatically. EventChargeFailed: nothing more is tried for this charge. For a one-off charge it can arrive again for the same chargeId with a new decline class; keep the latest. EventChargeRetrying: an agreement period was declined softly, and the platform tries the same card again.

View Source
const (
	EventVerificationSucceeded = "verification.succeeded"
	EventVerificationFailed    = "verification.failed"
)

Webhook event types of verify-only sessions. A verify-only session never fires payment.* events. EventVerificationFailed fires when the issuer declined or the payer abandoned the checkout; no card is saved.

View Source
const (
	ChargeOutcomeSucceeded = "succeeded"
	ChargeOutcomeRetrying  = "retrying"
	ChargeOutcomeFailed    = "failed"
)

Outcomes of a charge on charge.* events, on ChargeWebhookData.Outcome, in the gateway's own order. ChargeOutcomeRetrying: an agreement period was declined softly and the platform tries the same card again, at NextAttemptAt.

View Source
const (
	WebhookEndpointStatusActive   = "active"
	WebhookEndpointStatusInactive = "inactive"
)

Webhook endpoint status values, in the gateway's own order. inactive means deliveries are paused: the endpoint was switched off in the dashboard, or after repeated failed deliveries. An inactive endpoint does not count toward the limit of active endpoints.

View Source
const ConsentTextVersionRecurring202609 = "recurring-2026-09"

ConsentTextVersionRecurring202609 is the version of the platform's recurring payment consent text that the subscription session accepts. Show this version's text on your page. Older versions stay accepted for agreements that were shown them.

View Source
const DefaultWebhookTolerance = 5 * time.Minute

DefaultWebhookTolerance is how far a delivery's timestamp may sit from your clock before VerifyWebhook rejects it. It matches the gateway's own window.

View Source
const ErrorCodeIdempotencyKeyRequired = "IDEMPOTENCY_KEY_REQUIRED"

ErrorCodeIdempotencyKeyRequired (HTTP 400, every keyed write): the Idempotency-Key header was missing or too long. The SDK refuses such a key before sending, so this only arrives if something between you and the API dropped the header.

View Source
const ErrorCodeInsufficientScope = "INSUFFICIENT_SCOPE"

ErrorCodeInsufficientScope (HTTP 403, on every route that needs a scope): the request was signed correctly, but the API key does not hold the scope the route needs. It arrives as an *AuthError with HTTPStatus 403. A key's scopes are fixed when it is issued, so a retry with the same key gets the same answer: call the route with a key that holds its scope.

The scopes are payments (sessions, charges, revokes, refunds, plans and agreements), transactions:read (ListTransactions) and webhooks:manage (the webhook endpoint routes). A key holds either payments, or one or both of the other two: use a separate reporting key for transactions and webhook endpoints.

View Source
const ErrorCodeValidationError = "VALIDATION_ERROR"

ErrorCodeValidationError (HTTP 400) is the envelope code of a request that failed validation. error.validationErrors on APIError.Raw names each field and its field code (the ValidationErrorCodes).

View Source
const ErrorCodeVerifyOnlyNotEnabled = "VERIFY_ONLY_NOT_ENABLED"

ErrorCodeVerifyOnlyNotEnabled (HTTP 403, CreateCheckoutSession): the session asked for VerifyOnly, and verify-only sessions are not enabled for your account. It arrives as an *AuthError with HTTPStatus 403. Nothing was created and the idempotency key is not spent. We switch it on for your account: ask Dominaite support.

View Source
const WebhookSignatureHeader = "X-Webhook-Signature"

WebhookSignatureHeader is the header Dominaite signs each delivery with. An endpoint can be configured to use a different header name, but this is the default and the one the dashboard shows.

Variables

AgreementStatuses is the agreement status vocabulary, in the gateway's order.

AuthenticationErrorCodes are the HTTP 401 codes, in the gateway's own order.

View Source
var AuthorizationErrorCodes = []string{
	ErrorCodeInsufficientScope,
}

AuthorizationErrorCodes are the codes a correctly signed request can be refused with because of what the key may do, in the gateway's own order. None is retryable.

ChargeErrorCodes is the complete v1 vocabulary of ChargeError.ErrorCode: the seven codes of the published contract first, then the 404, 409 and 422 codes of the charge route. Unknown 4xx codes still arrive as a *ChargeError; branch on the ones you know.

ChargeOutcomes is the charge outcome vocabulary, in the gateway's order. Treat a value you do not know as not final.

ChargeStatuses is the complete v1 charge status vocabulary. Treat a value outside this list as still open.

DeclineClasses is the complete v1 decline class vocabulary.

View Source
var ErrDominaite = errors.New("dominaite")

ErrDominaite matches every error this SDK returns, for a single errors.Is catch-all:

if errors.Is(err, dominaite.ErrDominaite) { ... }

For the specific kind, use errors.As with *RefusalError, *ChargeError, *RevokeError, *RefundError, *AuthError, *RateLimitError, *APIError, *TransportError or *ValidationError, or errors.As with the Error interface to catch any of them while keeping the message.

IdempotencyErrorCodes are the idempotency codes, in the gateway's own order: ErrorCodeIdempotencyKeyRequired (HTTP 400), then the replay refusals of a session create (HTTP 200 with success false).

Integrations is the complete v1 integration vocabulary.

IntervalUnits is the interval unit vocabulary, in the gateway's order.

PastDueReasons is the past-due reason vocabulary, in the gateway's order.

PaymentMethodCategories is every payment method category the merchant API reports, in the gateway's own order.

Reporting data, not a money-flow switch: a wallet payment refunds, captures and disputes exactly like a card payment.

PlanStatuses is the plan status vocabulary, in the gateway's order.

RecurringErrorCodes are the codes of the plan and agreement routes, in the gateway's own order. They arrive on APIError.ErrorCode.

RefundErrorCodes is the complete v1 vocabulary of RefundError.ErrorCode. Unknown codes still arrive as a *RefundError; branch on the ones you know.

RefundFailureCodes is the complete v1 vocabulary of Refund.FailureCode.

RefundStatuses is the complete v1 refund status vocabulary. Treat a value outside this list as still open.

RevokeErrorCodes is the complete v1 vocabulary of RevokeError.ErrorCode.

SessionRefusalErrorCodes are every RefusalError.ErrorCode a session create can answer with HTTP 200 and success false: the five replay and availability codes of the published contract, ErrorCodeStorefrontMismatch (a replay of a key minted for another storefront), then the SubscriptionSessionRefusalCodes. Treat a code you do not know as not retryable.

Statuses is the complete v1 status vocabulary. A value outside this list means the gateway grew a status this SDK does not know yet: keep polling, never treat it as terminal.

StoredPaymentMethodRetiredReasons is the complete v1 retired-reason vocabulary. Treat a value outside this list as retired for an unknown reason.

StoredPaymentMethodStatuses is the complete v1 stored-payment-method status vocabulary. Treat a value outside this list as not chargeable.

StorefrontErrorCodes are the storefront codes a session create can be refused with, in the gateway's own order: ErrorCodeStorefrontMismatch (HTTP 400), ErrorCodeStorefrontInactive (409) and ErrorCodeStorefrontNotWhitelisted (409). None is retryable. They arrive as an *APIError, not as the HTTP 200 refusal shape, so they are not session refusal codes.

SubscriptionSessionRefusalCodes are the refusal codes of a session created with AgreementID, in the gateway's own order. Treat a code you do not know as not retryable.

TransactionTypes is the transaction type vocabulary, in the gateway's order.

TransientErrorCodes are the codes the gateway asks you to retry, in its own order. The client retries both for you (see WithMaxRetries).

ValidationErrorCodes are the field codes of a validation failure, in the gateway's own order. The list can grow: treat a code you do not know as a rejected field.

WalletTypes is the wallets the gateway names today, pinned against the published contract. The field can carry a lower-cased identifier not in this list yet: treat an unknown value as a valid wallet, not an error.

WebhookEndpointStatuses is the endpoint status vocabulary, in the gateway's order.

WebhookErrorCodes are the codes of the webhook endpoint routes, in the gateway's own order. They arrive on APIError.ErrorCode.

Functions

func CurrencyExponent added in v0.3.0

func CurrencyExponent(currency string) (int, error)

CurrencyExponent returns the number of minor-unit digits the gateway uses for a currency: 2 for EUR (1 EUR is 100 cents), 0 for JPY and HUF, 3 for KWD. HUF differs from ISO 4217 on purpose; see ToMinorUnits. The code is case-insensitive. ISK, KRW, OMR, JOD and TND are refused as not supported, and an unknown currency is refused too, both with a *ValidationError.

func IsPaid added in v0.3.0

func IsPaid(status string) bool

IsPaid reports whether a status means the payment is complete: true for StatusSucceeded only. StatusRequiresCapture is not paid yet (the funds are held, not captured), and neither is anything this SDK does not recognise.

func IsRefundTerminal added in v0.3.1

func IsRefundTerminal(status string) bool

IsRefundTerminal reports whether a refund status is final, so polling can stop: succeeded and failed. pending, processing and any status this SDK does not recognise are still open.

func IsTerminal added in v0.3.0

func IsTerminal(status string) bool

IsTerminal reports whether a status is final, so polling can stop: succeeded, failed, cancelled, abandoned, refunded and partially_refunded. Everything else is still open and worth polling, including pending, processing, requires_capture, disputed and any status this SDK does not recognise: a value the API adds later must never close a live order.

func OrderIdempotencyKey added in v0.3.0

func OrderIdempotencyKey(scope, orderID string, amountMinor int64, currency string) (string, error)

OrderIdempotencyKey builds the idempotency key for one payment of one order: "{scope}-{orderID}-{amountMinor}-{CURRENCY}", for example OrderIdempotencyKey("checkout", "order-1042", 2500, "eur") is "checkout-order-1042-2500-EUR".

Derive the key from the order, never per call. Your payment page will be re-entered: a reload, the Back button, a second tab, a retry after a timeout. The same order at the same amount produces the same key every time, so each of those replays the session already open (same TransactionID, same cashier handles) instead of opening a second payment. A changed amount or currency (the cart was edited) produces a new key and therefore a fresh session; the old key replayed with a new amount would be refused with IDEMPOTENCY_KEY_REUSED.

scope is a fixed label for the call site ("checkout", "renewal"), so two different kinds of payment for the same order never share a key. SaveCard is part of a session's identity too: if the payer can flip it after a session was opened, use a different scope for each choice (for example "checkout-save"). Keep scope free of values that vary per request.

amountMinor is in MINOR units and must be positive. currency is ISO 4217 and is uppercased here, so "eur" and "EUR" give the same key. Returns a *ValidationError when scope or orderID is blank, the amount is not positive, the currency is not three ASCII letters, or the key breaks the key rules: at most 100 characters, visible ASCII only (0x21 to 0x7E), so an order id with a space or a non-ASCII letter is refused rather than silently rewritten.

func Sign

func Sign(in SignInput) string

Sign builds the X-Signature value for one request: lowercase hex HMAC-SHA256 over

"{timestamp}\n{METHOD}\n{path}\n{idempotencyKey}\n{sha256hex(body)}"

The idempotency key is INSIDE the signature, so a captured request cannot be replayed with a different key to mint extra sessions. The server rejects timestamps more than 5 minutes off, so keep your server clock on NTP.

The client signs for you. Sign is exported so you can pin the recipe against the offline test vector before calling the live API, and so you can debug an INVALID_SIGNATURE without reading this package's source.

func ToMinorUnits added in v0.3.0

func ToMinorUnits(amount, currency string) (int64, error)

ToMinorUnits converts a decimal amount string to the integer minor units Amount takes, by the exponent the gateway uses for the currency:

ToMinorUnits("0.30", "EUR")  // 30
ToMinorUnits("25", "EUR")    // 2500
ToMinorUnits("1500", "JPY")  // 1500
ToMinorUnits("1500", "HUF")  // 1500: whole forints, NOT ISO 4217's 150000
ToMinorUnits("1.250", "KWD") // 1250

Known currencies: EUR, USD, GBP, CAD, AUD, CHF, BGN, RON, PLN, CZK, SEK, DKK, NOK (2 decimals), JPY, HUF (0) and BHD, KWD (3).

The conversion is exact, done on the digits, never through a float (19.99 as a float64 times 100 is 1998.9999999999998, which truncates to 1998). The amount is plain digits with an optional point: no sign, no thousands separators, no exponent. More fractional digits than the currency has is refused, zeros included ("0.305" and "25.000" EUR, "100.0" JPY): nothing is rounded or trimmed, because rounding a price is a business decision, not the SDK's. Surrounding whitespace is ignored.

Returns a *ValidationError for a malformed amount, an unknown or unsupported currency, or an amount too large for int64.

Types

type APIError

type APIError struct {
	HTTPStatus int
	ErrorCode  string
	// Raw is the whole envelope the API sent, for fields not modelled above.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

APIError means the API answered, but with an unexpected or rejecting response. HTTPStatus carries the code. A 404 from GetStatus means an unknown transaction id; a 404 from ChargePaymentMethod or RevokePaymentMethod an id that is not yours. A 3xx means something in front of the API answered with a redirect: the SDK never follows one and never treats its body as a real response, because the API itself does not redirect.

A key replayed with a different body does NOT arrive here. The API answers HTTP 200 with success false and IDEMPOTENCY_KEY_REUSED, so it reaches you as a *RefusalError. A 429 does not arrive here either: it is a *RateLimitError.

ErrorCode is the machine-readable code when the API sent one, so input rejections can be branched on rather than string-matched. A 400 carrying IDEMPOTENCY_KEY_REQUIRED means the Idempotency-Key header was missing or empty. A 409 carrying ErrorCodeStorefrontNotWhitelisted or ErrorCodeStorefrontInactive, or a 400 carrying ErrorCodeStorefrontMismatch, means the storefront refused the session. Empty when the API named no code.

Raw is the whole envelope the API sent with a rejection, for fields not modelled above: error.params, error.validationErrors and error.traceId. It is nil when there was no JSON envelope, and on an unexpected 2xx.

func (APIError) Error

func (e APIError) Error() string

func (APIError) Is

func (e APIError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type Agreement added in v0.5.0

type Agreement struct {
	// ID is agr_ followed by 32 hex characters. It is case-sensitive: store it
	// and send it back exactly as it is.
	ID                string `json:"id"`
	PlanID            string `json:"planId"`
	CustomerReference string `json:"customerReference"`
	// StoredPaymentMethodID is the pm_ id of the card on file, empty until one
	// is saved.
	StoredPaymentMethodID string `json:"storedPaymentMethodId,omitempty"`
	// Status is one of the AgreementStatus* values.
	Status string `json:"status"`
	// Amount is the charge per period, in MINOR units of Currency.
	Amount int64 `json:"amount"`
	// MaxAmountPerPeriod is the most one charge may be on a variable
	// agreement, in MINOR units. nil on a fixed agreement.
	MaxAmountPerPeriod *int64 `json:"maxAmountPerPeriod,omitempty"`
	Currency           string `json:"currency"`
	// IntervalUnit is one of the IntervalUnit* values.
	IntervalUnit  string `json:"intervalUnit"`
	IntervalCount int    `json:"intervalCount"`
	// PeriodCount is nil when the agreement bills until it is cancelled.
	PeriodCount *int `json:"periodCount,omitempty"`
	TrialDays   int  `json:"trialDays"`
	// NextChargeAt is ISO 8601 UTC, empty while pending and once the
	// agreement has ended.
	NextChargeAt string `json:"nextChargeAt,omitempty"`
	// PastDueReason is one of the PastDueReason* values while the agreement
	// is past_due, empty otherwise.
	PastDueReason string `json:"pastDueReason,omitempty"`
	// PastDueAt is ISO 8601 UTC, when the agreement went past_due.
	PastDueAt string `json:"pastDueAt,omitempty"`
	// Consent is nil until consent is captured.
	Consent *AgreementConsent `json:"consent,omitempty"`
	// Version starts at 1 and rises when the price changes for a future
	// period.
	Version int `json:"version"`
	// Sequence is the agreement's transition counter as of this read: the
	// Data.Sequence of the latest agreement.* event, 0 before the first
	// transition. Compare it with the highest sequence you processed for the
	// agreement.
	Sequence int64 `json:"sequence"`
	// CreatedAt is ISO 8601 UTC.
	CreatedAt string `json:"createdAt"`
	// ActivatedAt is ISO 8601 UTC, empty while pending.
	ActivatedAt string `json:"activatedAt,omitempty"`
	// CancelledAt is ISO 8601 UTC, empty unless cancelled.
	CancelledAt string `json:"cancelledAt,omitempty"`

	// RequestID is the API's id of the call that returned this agreement.
	// Quote it to support. Empty when the API sent none.
	RequestID string `json:"-"`

	// Raw is the unparsed agreement object, for fields this struct does not
	// model yet.
	Raw json.RawMessage `json:"-"`
}

Agreement is one customer's subscription to a plan. The price and the interval are copied from the plan when the agreement is created; a later plan change never re-prices a running agreement.

type AgreementConsent added in v0.5.0

type AgreementConsent struct {
	TextVersion    string `json:"textVersion"`
	AcceptedAt     string `json:"acceptedAt"`
	IP             string `json:"ip"`
	ReceivedAt     string `json:"receivedAt,omitempty"`
	ReceivedFromIP string `json:"receivedFromIp,omitempty"`
}

AgreementConsent is the consent recorded on an agreement. TextVersion, AcceptedAt and IP are stored exactly as they were sent, on CreateAgreement or on the subscription session. ReceivedAt and ReceivedFromIP are the platform's own receipt: when the consent reached the platform, and from which address.

type AgreementWebhookData added in v0.7.0

type AgreementWebhookData struct {
	// ID is the agr_ id: the ordering key of agreement.* events.
	ID                string `json:"id"`
	PlanID            string `json:"planId"`
	CustomerReference string `json:"customerReference"`
	// StoredPaymentMethodID is the pm_ id of the card on file, empty until one
	// is saved.
	StoredPaymentMethodID string `json:"storedPaymentMethodId"`
	// Status is one of the AgreementStatus* values; PreviousStatus is the
	// status it moved from.
	Status         string `json:"status"`
	PreviousStatus string `json:"previousStatus"`
	// Amount is the charge per period, in MINOR units of Currency.
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
	// IntervalUnit is one of the IntervalUnit* values.
	IntervalUnit  string `json:"intervalUnit"`
	IntervalCount int    `json:"intervalCount"`
	// PeriodCount is nil when the agreement bills until it is cancelled.
	PeriodCount *int `json:"periodCount"`
	TrialDays   int  `json:"trialDays"`
	// NextChargeAt, ActivatedAt and CancelledAt are as the API sent them,
	// empty when null.
	NextChargeAt string `json:"nextChargeAt"`
	ActivatedAt  string `json:"activatedAt"`
	CancelledAt  string `json:"cancelledAt"`
	// Version starts at 1 and rises when the price changes for a future
	// period.
	Version int `json:"version"`
	// Sequence is the same value as WebhookData.Sequence.
	Sequence int64 `json:"sequence"`

	// Raw is the unparsed data object, for fields not modelled above.
	Raw json.RawMessage `json:"-"`
}

AgreementWebhookData is the data of an agreement.* event: the agreement after the change. The fields are the ones GetAgreement returns, plus PreviousStatus. Webhooks send every field, null when empty: a null string reads as "", a null number as nil.

type AuthError

type AuthError struct {

	// HTTPStatus is 401 or 403.
	HTTPStatus int
	ErrorCode  string
	// contains filtered or unexported fields
}

AuthError means the API refused the request before it did anything: HTTP 401 or 403, on HTTPStatus. A retry with the same key and config gets the same answer.

HTTP 401: the API could not authenticate the request. ErrorCode is one of INVALID_API_KEY, INVALID_SIGNATURE, TIMESTAMP_OUT_OF_RANGE or IP_NOT_ALLOWED. Fix the key id, the secret, the server clock or the caller allowlist.

HTTP 403: the request was signed correctly, so the key id, the secret and the clock are fine, but the key may not do this. ErrorCode is ErrorCodeInsufficientScope (the key does not hold the scope the route needs) or ErrorCodeVerifyOnlyNotEnabled (a verify-only session on an account without verify-only sessions). Message says what to do.

func (AuthError) Error

func (e AuthError) Error() string

func (AuthError) Is

func (e AuthError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type ChargeError added in v0.3.0

type ChargeError struct {
	HTTPStatus int
	ErrorCode  string
	// Charge is the charge row the gateway attached to its answer, when it did.
	Charge *PaymentMethodCharge
	// TransactionID is Charge.TransactionID, for polling GetStatus. Empty
	// without a charge.
	TransactionID string
	// Raw is the whole envelope the gateway sent, for fields not modelled above.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

ChargeError means the gateway answered a charge with an error code instead of a charge result: HTTP 404, 409, 422, 502 or 503 with one of the ChargeError* codes on ErrorCode (see each constant for what to do). HTTPStatus carries the status, Message the gateway's own text.

Charge is the charge row the gateway attached to its answer, when it did: always for CHARGE_OUTCOME_UNKNOWN, sometimes for CHARGE_FAILED, never for the rest. TransactionID is a shortcut for Charge.TransactionID, empty when there is no charge:

charge, err := client.ChargePaymentMethod(ctx, id, params)
var chargeErr *dominaite.ChargeError
if errors.As(err, &chargeErr) && chargeErr.ErrorCode == dominaite.ChargeErrorOutcomeUnknown {
	// poll client.GetStatus(ctx, chargeErr.TransactionID); do not retry under a new key
}

Only authentication (401/403, *AuthError), validation (400, *APIError), rate limiting (429, *RateLimitError), network failures and a 5xx without one of the charge route's own 5xx codes (*TransportError, with the code when there was one) keep their generic errors on this route. A 404 with a code, PAYMENT_METHOD_NOT_FOUND or AGREEMENT_NOT_FOUND, is a *ChargeError.

func (ChargeError) Error added in v0.3.0

func (e ChargeError) Error() string

func (ChargeError) Is added in v0.3.0

func (e ChargeError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type ChargePaymentMethodParams added in v0.3.0

type ChargePaymentMethodParams struct {
	// Amount is in MINOR units: 2500 is 25.00 EUR. Integers only.
	Amount int64 `json:"amount"`
	// Currency is ISO 4217, e.g. "EUR".
	Currency string `json:"currency"`
	// OrderReference is your own order id, at most 100 characters. It shows up
	// in your dashboard.
	OrderReference string `json:"orderReference"`
	Description    string `json:"description,omitempty"`

	// AgreementID is the agr_ id of the agreement this charge is made under.
	// Empty charges the card on your own authority, as with any card on file:
	// only the per-charge cap and the volume limits apply. When set, the
	// charge must be on the agreement's terms: its payment method, its
	// consent, its currency and its amount rule. The gateway refuses anything
	// else with a 422 *ChargeError, or a 404 *APIError (AGREEMENT_NOT_FOUND)
	// for an agreement that is not yours. The charge records the agreement,
	// so a dispute can be answered with the consent the customer gave.
	AgreementID string `json:"agreementId,omitempty"`

	// IdempotencyKey is required. Derive it from what you are charging for
	// (the order, the billing period), never per attempt: retrying with the
	// same key never charges the card twice, so on a timeout retry with the
	// same key. It travels in the header and in the signature, never in the
	// body. 1 to 100 visible ASCII characters; anything else is refused with a
	// *ValidationError before anything is sent.
	IdempotencyKey string `json:"-"`

	// Extra carries any additional field the API accepts that this struct does
	// not model yet. Keys here are merged into the JSON body and replace a
	// modelled field of the same name.
	Extra map[string]any `json:"-"`
}

ChargePaymentMethodParams are the parameters for Client.ChargePaymentMethod. Amount, Currency and OrderReference are required.

func (ChargePaymentMethodParams) MarshalJSON added in v0.5.0

func (p ChargePaymentMethodParams) MarshalJSON() ([]byte, error)

MarshalJSON emits the request body: the modelled fields in declaration order, with Extra merged in. IdempotencyKey is excluded; it is a header, not a body field.

type ChargeWebhookData added in v0.7.0

type ChargeWebhookData struct {
	// ChargeID is ch_ followed by 32 hex characters: the same id
	// ChargePaymentMethod returned for a one-off charge.
	ChargeID string `json:"chargeId"`
	// TransactionID is the transaction the charge created; readable with
	// GetStatus.
	TransactionID string `json:"transactionId"`
	// StoredPaymentMethodID is the pm_ id of the card that was charged.
	StoredPaymentMethodID string `json:"storedPaymentMethodId"`
	// AgreementID is the agr_ id the charge belongs to. Empty for a one-off
	// charge without an agreement.
	AgreementID string `json:"agreementId"`
	// CustomerReference is the agreement's customer reference. Empty without
	// an agreement.
	CustomerReference string `json:"customerReference"`
	// Outcome is one of the ChargeOutcome* values.
	Outcome string `json:"outcome"`
	// PeriodNumber is the agreement period the charge pays, from 1. nil for a
	// one-off charge.
	PeriodNumber *int `json:"periodNumber"`
	// AttemptNumber counts the attempts on this period, from 1. nil when the
	// gateway did not count one.
	AttemptNumber *int `json:"attemptNumber"`
	// Amount is in MINOR units of Currency.
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
	// PaymentMethod is the brand and last four digits of the card. nil when
	// the provider reported neither.
	PaymentMethod *ChargeWebhookPaymentMethod `json:"paymentMethod"`
	// OrderReference and Description are what the charge was sent with.
	OrderReference string `json:"orderReference"`
	Description    string `json:"description"`
	// DeclineClass is one of the DeclineClass* constants on charge.failed and
	// charge.retrying, empty otherwise. DeclineCode is the issuer's code,
	// verbatim, for your logs.
	DeclineClass string `json:"declineClass"`
	DeclineCode  string `json:"declineCode"`
	// NextAttemptAt is when the platform tries the card again, on
	// charge.retrying. NextChargeAt is the agreement's next charge, when
	// there is one. Both are as the API sent them, empty when null.
	NextAttemptAt string `json:"nextAttemptAt"`
	NextChargeAt  string `json:"nextChargeAt"`
	// Sequence is the same value as WebhookData.Sequence.
	Sequence int64 `json:"sequence"`

	// Raw is the unparsed data object, for fields not modelled above.
	Raw json.RawMessage `json:"-"`
}

ChargeWebhookData is the data of a charge.* event: a charge on a stored card, one-off (ChargePaymentMethod) or placed by the platform for an agreement period. Webhooks send every field, null when empty: a null string reads as "", a null number as nil.

type ChargeWebhookPaymentMethod added in v0.7.0

type ChargeWebhookPaymentMethod struct {
	// Brand is the lower-cased card brand (visa, mastercard, ...). Empty when
	// the provider did not report one.
	Brand string `json:"brand"`
	// Last4 is the card's last four digits. Empty when unknown.
	Last4 string `json:"last4"`
}

ChargeWebhookPaymentMethod is the card on a charge.* event.

type CheckoutSession

type CheckoutSession struct {
	TransactionID string `json:"transactionId"`
	OrderID       string `json:"orderId"`
	// CashierKey feeds the widget's data-cashier-key. A per-payment value, not
	// a credential.
	CashierKey string `json:"cashierKey"`
	// CashierToken feeds the widget's data-cashier-token. A per-payment value,
	// not a credential.
	CashierToken string `json:"cashierToken"`
	// Amount is in MINOR units.
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
	// ExpiresAt is ISO 8601. Sessions are valid for 2 hours.
	ExpiresAt string `json:"expiresAt"`
	// Integration echoes what the session was created for: IntegrationWidget or
	// IntegrationFields. For fields, CashierKey and CashierToken are the card
	// fields key and session token.
	Integration Integration `json:"integration"`
	// ClientSecret is set only for IntegrationFields: the browser credential
	// the checkout.js drop-in sends for this session. Opaque, at most 128
	// characters, the same on every replay. Hand it to the payer's page with the
	// other session values; never log it or keep it past the session.
	ClientSecret string `json:"clientSecret,omitempty"`
	// VerifyOnly is true on a verify-only session: Amount is the verification
	// hold, which is released once the card is saved. False on a payment
	// session.
	VerifyOnly bool `json:"verifyOnly,omitempty"`
	// AgreementID is the agr_ id of the agreement this subscription session
	// takes the first payment for. Empty on a one-off checkout.
	AgreementID string `json:"agreementId,omitempty"`

	// RequestID is the API's id of the call that returned this: the
	// Dominaite-Request-Id response header, or metadata.requestId in the body
	// when the header is absent. Quote it to support. Empty when the API sent
	// neither.
	RequestID string `json:"-"`

	// Raw is the unparsed payload, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

CheckoutSession is what CreateCheckoutSession returns.

type CheckoutStatus

type CheckoutStatus struct {
	TransactionID  string `json:"transactionId"`
	OrderID        string `json:"orderId"`
	OrderReference string `json:"orderReference,omitempty"`
	// PSPReference is the payment processor's reference for the transaction.
	// Empty (null or absent on the wire) until known, and on a sale settled by
	// reconciliation without a processor webhook. Older gateways omit it.
	PSPReference string `json:"pspReference,omitempty"`
	// Status is one of the Status* constants.
	Status string `json:"status"`
	// Amount is in MINOR units.
	Amount         int64  `json:"amount"`
	Currency       string `json:"currency"`
	RefundedAmount int64  `json:"refundedAmount,omitempty"`
	// PaymentMethod is how the payer paid, one of PaymentMethodCategories.
	// Empty (null or absent on the wire) while the payment is still open, with
	// no method chosen yet, and on older transactions. Reporting data only.
	PaymentMethod string `json:"paymentMethod,omitempty"`
	// WalletType is which wallet, when PaymentMethod is PaymentMethodWallet.
	// A value outside WalletTypes is a valid wallet this SDK does not name yet,
	// not an error. Empty (null or absent on the wire) for non-wallet payments.
	WalletType string `json:"walletType,omitempty"`
	CreatedAt  string `json:"createdAt"`
	UpdatedAt  string `json:"updatedAt,omitempty"`
	// ExpiresAt is present while the session is still payable.
	ExpiresAt string `json:"expiresAt,omitempty"`
	// StoredPaymentMethod is the card kept on file by a session created with
	// SaveCard. Present once the payment is approved, and it stays present
	// after a revoke with Status StoredPaymentMethodStatusRevoked; nil (absent
	// on the wire) until then, for sessions without SaveCard, and for declined
	// or abandoned ones. Store StoredPaymentMethod.ID against your customer -
	// it is what Client.ChargePaymentMethod takes.
	//
	// Not to be confused with PaymentMethod, which is the string category of
	// how the payer paid ("card", "wallet", ...).
	StoredPaymentMethod *StoredPaymentMethod `json:"storedPaymentMethod,omitempty"`
	// VerifyOnly is true on a verify-only session: a card check, not a
	// payment. Status stays the raw status (requires_capture, then cancelled
	// once the hold is released). A non-nil StoredPaymentMethod is what says
	// the card is verified. False on every other session.
	VerifyOnly bool `json:"verifyOnly,omitempty"`
	// DeclineClass is one of the DeclineClass* constants on a failed payment.
	// Empty while the payment is open and on every other status. It can be
	// empty on a failed payment too, for example when nobody declined the
	// payment. A value outside DeclineClasses is a class this SDK does not name
	// yet, not an error.
	DeclineClass string `json:"declineClass,omitempty"`
	// IsRecurring is true for a charge on a stored card without the payer
	// present (a recurring charge), false for a checkout payment.
	IsRecurring bool `json:"isRecurring,omitempty"`
	// Agreement is the agreement the session took the first payment for, or
	// the agreement a charge was placed under. nil on a one-off checkout or
	// charge.
	Agreement *CheckoutStatusAgreement `json:"agreement,omitempty"`
	// Sequence is the data.sequence of the latest charge.* event about this
	// transaction: compare it with the events you processed. 0 on a checkout
	// payment, which sends payment.* events only, and before the first
	// charge.* event.
	Sequence int64 `json:"sequence,omitempty"`

	// RequestID is the API's id of the call that returned this: the
	// Dominaite-Request-Id response header, or metadata.requestId in the body
	// when the header is absent. Quote it to support. Empty when the API sent
	// neither.
	RequestID string `json:"-"`

	// Raw is the unparsed payload, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

CheckoutStatus is what GetStatus returns.

type CheckoutStatusAgreement added in v0.5.0

type CheckoutStatusAgreement struct {
	// ID is the agr_ id, case-sensitive.
	ID string `json:"id"`
	// Status is one of the AgreementStatus* values. It stays pending after a
	// declined or abandoned subscription session: the subscription did not
	// start.
	Status string `json:"status"`
	// NextChargeAt is ISO 8601 UTC. Empty while pending and once the
	// agreement has ended.
	NextChargeAt string `json:"nextChargeAt,omitempty"`
}

CheckoutStatusAgreement is the agreement on a status read: enough to know whether the subscription started. Read the whole agreement with GetAgreement.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a server-side client for the Dominaite merchant API. It is safe for concurrent use.

func New

func New(keyID, secret string, opts ...Option) (*Client, error)

New builds a client from your API key id (dmk_...) and secret (dms_...), both from the dashboard's Website integration tab. It returns a *ValidationError when either credential has the wrong prefix, which catches a swapped key id and secret before anything is sent. It also returns one when WithBaseURL is missing, empty or whitespace, or is not https:// outside loopback.

func (*Client) CancelAgreement added in v0.5.0

func (c *Client) CancelAgreement(ctx context.Context, agreementID string) error

CancelAgreement cancels an agreement from pending, active or past_due: no further charge is scheduled. A charge already placed is not reversed. Cancelling a cancelled agreement succeeds again, so a retry after a timeout is safe.

Errors it returns: as GetAgreement, and an *APIError with HTTPStatus 409 and ErrorCode AGREEMENT_ENDED when the agreement already completed or failed.

func (*Client) ChargePaymentMethod added in v0.3.0

func (c *Client) ChargePaymentMethod(ctx context.Context, paymentMethodID string, params ChargePaymentMethodParams) (*PaymentMethodCharge, error)

ChargePaymentMethod charges a card kept on file, off-session: no widget, no payer present.

paymentMethodID is the ID from GetStatus().StoredPaymentMethod of a session you created with SaveCard. The charge is signed like a session and carries an Idempotency-Key, which you must set (derive it from the billing period, never per attempt), so retrying after a timeout WITH THE SAME KEY never charges the card twice: the gateway replays its first answer, HTTP status included.

The HTTP status is the contract on this route. 201 (200 on a replay) returns the charge, Status ChargeStatusSucceeded, ChargeStatusPending or ChargeStatusCancelled. 402 returns the charge too: a decline is not an error. The charge has Status ChargeStatusFailed, and DeclineClass, when it is set, tells you whether to give up on the card (hard), wait (soft_funds, soft_other) or bring the customer back for a hosted session (soft_sca_required). ChargeStatusPending is not terminal - poll GetStatus(charge.TransactionID).

params.AgreementID charges under an agreement, on its terms.

Errors it returns:

  • *ValidationError: bad arguments; nothing was sent.
  • *ChargeError: 404, 409, 422, 502 or 503 with a code; branch on ErrorCode. 404 PAYMENT_METHOD_NOT_FOUND is an id that is not yours. CHARGE_OUTCOME_UNKNOWN carries the charge row (Charge, TransactionID): poll GetStatus with it, never retry under a new key.
  • *AuthError: wrong credentials, bad signature, clock off, IP not allowlisted.
  • *APIError: 400 validation, or an unexpected response; inspect HTTPStatus.
  • *RateLimitError: HTTP 429; back off and reschedule with the same key.
  • *TransportError: network failure or a 5xx without a charge code (ErrorCode keeps the code it had). Safe to retry WITH the same key.

func (*Client) CreateAgreement added in v0.5.0

func (c *Client) CreateAgreement(ctx context.Context, params CreateAgreementParams) (*Agreement, error)

CreateAgreement creates a pending agreement that subscribes one customer to one of your active plans. Nothing is charged. To take the first payment, save the card and activate the agreement, open a subscription session: CreateCheckoutSession with AgreementID.

params.IdempotencyKey is required and signed. The same key with the same body answers the agreement it created, with HTTP 200 instead of 201. The same key with a different body is refused with IDEMPOTENCY_KEY_REUSED.

Errors it returns:

  • *ValidationError: a missing plan id or customer reference, or a bad IdempotencyKey; nothing was sent.
  • *APIError: a 4xx with ErrorCode, e.g. 404 PLAN_NOT_FOUND, 409 PLAN_NOT_ACTIVE, 422 AGREEMENT_CONSENT_REQUIRED (see the codes next to ErrorCodePlanNotFound). Nothing was created.
  • *AuthError, *RateLimitError: as everywhere else.
  • *TransportError: network failure or 5xx. Nothing was created on a 503; retry WITH the same key.

func (*Client) CreateCheckoutSession

func (c *Client) CreateCheckoutSession(ctx context.Context, params CreateCheckoutSessionParams) (*CheckoutSession, error)

CreateCheckoutSession opens a hosted checkout session for one payment. With VerifyOnly it saves the payer's card without a payment; with AgreementID it takes the first payment of a subscription.

Errors it returns:

  • *ValidationError: bad arguments, including a missing IdempotencyKey; nothing was sent.
  • *AuthError: 401 for wrong credentials, a bad signature, a clock that is off or an IP that is not allowlisted. 403 for a key without the payments scope, or VerifyOnly on an account without verify-only sessions. Fix the config, do not retry.
  • *RefusalError: the gateway refused the session; inspect ErrorCode.
  • *APIError: an unexpected or rejecting response; inspect HTTPStatus.
  • *RateLimitError: HTTP 429; back off and reschedule with the same idempotency key. Never retried for you.
  • *TransportError: network failure or 5xx, after the client's retries (WithMaxRetries) ran out. Safe to retry WITH the same idempotency key.

The client retries a *TransportError and PAYMENT_PROCESSING_UNAVAILABLE with the same idempotency key and body; see WithMaxRetries. A retry after an attempt that DID land gets that session back while it is still open. When the earlier attempt already moved money, ended, or cannot be handed back yet, the API answers with a replay refusal (*RefusalError, ErrorCode ALREADY_PROCESSED, PRIOR_ATTEMPT_FAILED or DUPLICATE_REQUEST). Recover through RefusalError.TransactionID with GetStatus when the API named one.

func (*Client) CreateCheckoutSessionWithRetry deprecated

func (c *Client) CreateCheckoutSessionWithRetry(ctx context.Context, params CreateCheckoutSessionParams, opts RetryOptions) (*CheckoutSession, error)

CreateCheckoutSessionWithRetry is CreateCheckoutSession with its own retry budget. It retries the same failures, with THE SAME idempotency key across every attempt.

Deprecated: CreateCheckoutSession retries on its own; set the budget with WithMaxRetries. This method stays for existing callers.

func (*Client) CreatePlan added in v0.5.0

func (c *Client) CreatePlan(ctx context.Context, params CreatePlanParams) (*Plan, error)

CreatePlan creates a subscription plan. Nothing is charged: an agreement subscribes a customer to the plan, and the hosted subscription session activates the agreement.

params.IdempotencyKey is required and signed. The same key with the same body answers the plan it created, with HTTP 200 instead of 201, so a create whose answer you lost is safe to repeat with the same key. The same key with a different body is refused with IDEMPOTENCY_KEY_REUSED. Keys are not case-sensitive: two keys that differ only in letter case are one key.

Errors it returns:

  • *ValidationError: a missing name, currency or interval unit, a non-positive amount, or a bad IdempotencyKey; nothing was sent.
  • *APIError: a 4xx with ErrorCode. 400 is a field the API refused (Raw carries error.validationErrors); 422 is IDEMPOTENCY_KEY_REUSED.
  • *AuthError, *RateLimitError: as everywhere else.
  • *TransportError: network failure or 5xx. Nothing was created on a 503; retry WITH the same key.

func (*Client) CreateRefund added in v0.3.1

func (c *Client) CreateRefund(ctx context.Context, transactionID string, params CreateRefundParams) (*Refund, error)

CreateRefund refunds a payment, in full or in part. transactionID is the id CreateCheckoutSession or ChargePaymentMethod returned; only card-not-present payments of your own account can be refunded.

params.IdempotencyKey is required and signed, exactly as on a charge. Derive it from your refund (the return or credit-note id): replaying the same key answers the same refund and never refunds twice, so on a timeout retry with the same key. The same key with a different amount, reason or payment is refused with IDEMPOTENCY_KEY_REUSED.

Leave params.Amount nil to refund everything still refundable. Partial refunds add up: the amount may not exceed what is left after earlier refunds and refunds still in progress.

The gateway answers HTTP 202: the refund is queued, not done. Read it back with GetRefund, or wait for the payment.refunded webhook, which fires once the money has moved. A failed refund sends no webhook, so poll GetRefund if you need to know about failures. A replay of the same key returns the refund as it stands now, so it doubles as a status read.

Errors it returns:

  • *ValidationError: bad arguments, including a missing IdempotencyKey; nothing was sent.
  • *RefundError: 400, 404, 409 or 422 with a RefundError* code; branch on ErrorCode, retry with the same key only when Retryable.
  • *AuthError: wrong credentials, bad signature, clock off, IP not allowlisted.
  • *APIError: an unexpected response; inspect HTTPStatus.
  • *RateLimitError: HTTP 429; back off and reschedule with the same key.
  • *TransportError: network failure or 5xx. Nothing was queued on a 5xx; retry WITH the same key.

func (*Client) CreateWebhookEndpoint added in v0.5.0

func (c *Client) CreateWebhookEndpoint(ctx context.Context, params CreateWebhookEndpointParams) (*WebhookEndpoint, error)

CreateWebhookEndpoint registers an endpoint that receives the events you name. The answer carries the endpoint's Secret, once: store it, and pass it to VerifyWebhook for every delivery. Lost the secret? Delete the endpoint and create it again.

The key needs the webhooks:manage scope. A key with the payments scope cannot hold it, so use a separate key for webhook endpoints.

params.IdempotencyKey is required and signed. The same key with the same URL and events answers the same endpoint without its secret, and registers nothing new. After no answer, a timeout or a 503, retry with the SAME key. When the first attempt never finished, the retry replaces the endpoint it left and answers a new id and a new secret. When the endpoint was registered and only its answer was lost, the retry answers that endpoint with an empty Secret: delete it, then create it again with a new key. The client never retries this call on its own, whatever WithMaxRetries says: a retry can answer without the secret, so you decide when to send one.

Errors it returns:

  • *ValidationError: an empty URL, no events, or a bad IdempotencyKey; nothing was sent.
  • *APIError: a 4xx with ErrorCode, e.g. 400 WEBHOOK_URL_REJECTED, 409 WEBHOOK_LIMIT_REACHED, 409 WEBHOOK_ALREADY_REGISTERED (Raw carries the existing endpoint's id at error.params.webhookId), 422 IDEMPOTENCY_KEY_REUSED, 409 DUPLICATE_REQUEST (retry with the same key after a second). Nothing was registered.
  • *AuthError: also 403 INSUFFICIENT_SCOPE for a key without webhooks:manage.
  • *RateLimitError: as everywhere else.
  • *TransportError: network failure or 5xx. Retry WITH the same key.

func (*Client) DeletePlan added in v0.5.0

func (c *Client) DeletePlan(ctx context.Context, planID string) error

DeletePlan archives a plan: it accepts no new agreements, and the agreements it already has continue unchanged. Deleting an archived plan succeeds again, so a retry after a timeout is safe. An archived plan stays readable.

Errors it returns: as GetPlan.

func (*Client) DeleteWebhookEndpoint added in v0.5.0

func (c *Client) DeleteWebhookEndpoint(ctx context.Context, webhookID string) error

DeleteWebhookEndpoint deletes one of your webhook endpoints. It stops receiving deliveries at once. The key needs the webhooks:manage scope.

An id that is unknown, not yours or already deleted answers an *APIError with HTTPStatus 404 and ErrorCode WEBHOOK_NOT_FOUND. A retry after a lost answer gets that 404 too: treat it as done.

Errors it returns: as ListWebhookEndpoints, and a *ValidationError for an id that is not a UUID.

func (*Client) GetAgreement added in v0.5.0

func (c *Client) GetAgreement(ctx context.Context, agreementID string) (*Agreement, error)

GetAgreement reads one of your agreements.

Errors it returns: an *APIError with HTTPStatus 404 and ErrorCode AGREEMENT_NOT_FOUND for an id that is not yours, or 400 INVALID_FORMAT for an id that is not an agreement id; *ValidationError for an id that cannot be a path segment; the generic errors otherwise.

func (*Client) GetIntegrationContract added in v0.5.0

func (c *Client) GetIntegrationContract(ctx context.Context) (*IntegrationContract, error)

GetIntegrationContract reads the API's machine-readable contract: its routes, request and response fields, error codes and vocabularies, as the API serves them now. The route is public, so it needs no scope; the SDK signs the call anyway, like every call. Use it to check what the API supports, for example a field this SDK does not model yet. Version and the vocabularies are typed; everything else is on Raw.

Errors it returns: as Ping.

func (*Client) GetPlan added in v0.5.0

func (c *Client) GetPlan(ctx context.Context, planID string) (*Plan, error)

GetPlan reads one of your plans.

Errors it returns: an *APIError with HTTPStatus 404 and ErrorCode PLAN_NOT_FOUND for an id that is not yours, or 400 INVALID_FORMAT for an id that is not a plan id; *ValidationError for an id that cannot be a path segment; the generic errors otherwise.

func (*Client) GetRefund added in v0.3.1

func (c *Client) GetRefund(ctx context.Context, transactionID, refundID string) (*Refund, error)

GetRefund reads one refund of a payment: its Status, the Amount refunded once it succeeded, and FailureCode once it failed. Stop polling when IsRefundTerminal(refund.Status) is true.

Right after CreateRefund the refund may not be picked up yet: GetRefund then returns a *RefundError with ErrorCode REFUND_NOT_FOUND and Retryable set. Poll again for up to 60 seconds; after that the id is unknown.

Errors it returns: as CreateRefund, without the idempotency key rules. The read is signed with an empty idempotency key and an empty body.

func (*Client) GetStatus

func (c *Client) GetStatus(ctx context.Context, transactionID string) (*CheckoutStatus, error)

GetStatus reads the payment status of one of your checkout sessions.

Status is one of the Status* constants: pending, processing, succeeded, failed, refunded, partially_refunded, cancelled, disputed, requires_capture, abandoned. While a session is still payable the response also carries ExpiresAt; after that instant a pending session can only become abandoned. Amounts are integers in MINOR units. An unknown transaction id returns an *APIError with HTTPStatus 404.

StatusSucceeded is the only value that means the payment is complete. Keep polling on StatusPending, StatusProcessing and StatusRequiresCapture - none of them is terminal.

StatusRequiresCapture is NOT "unpaid": the payer has already paid and the funds are held awaiting capture. Never treat it as an abandoned order.

Treat any status you do not recognise as still-open too: a value the API adds later should make you keep polling, never silently close a live order.

StoredPaymentMethod is the card kept on file by a session created with SaveCard: present once the payment is approved (and it stays after a revoke, with Status revoked), nil until then, for sessions without SaveCard and for declined or abandoned ones. Its ID is what ChargePaymentMethod and RevokePaymentMethod take.

Poll after the payer returns to you, or on your order timeout. Not in a tight loop: the endpoint is rate limited per key (60/min per key, 120/min per IP), and going over returns a *RateLimitError.

func (Client) GoString added in v0.2.0

func (c Client) GoString() string

GoString covers %#v, which does not use String.

func (*Client) ListPlans added in v0.5.0

func (c *Client) ListPlans(ctx context.Context) ([]Plan, error)

ListPlans lists your plans: at most 200, active first, newest first within a status. The list has no pages; read a plan by id with GetPlan when you have more.

Errors it returns: *AuthError, *RateLimitError, *TransportError, and an *APIError for any other rejection.

func (*Client) ListRefunds added in v0.5.0

func (c *Client) ListRefunds(ctx context.Context, transactionID string) (*RefundList, error)

ListRefunds lists the refunds of one payment that were created through this API: pending first, then processing, then succeeded and failed, newest first within each group. Each refund has the fields GetRefund returns. A refund made in the dashboard or at the provider is not in the list, but RefundedAmount counts it. A new refund shows up once it is picked up, the same moment GetRefund stops answering REFUND_NOT_FOUND.

RefundableAmount is what a new refund could take right now, so read it before a partial refund to size it.

Errors it returns: as GetRefund. An unknown payment is a *RefundError with ErrorCode RefundErrorPaymentNotFound.

func (*Client) ListTransactions added in v0.5.0

func (c *Client) ListTransactions(ctx context.Context, params ListTransactionsParams) (*TransactionList, error)

ListTransactions reads one page of your card-not-present payments created in the window [From, To): checkout payments, saved-card charges, subscription charges and verify-only sessions. The rows come oldest first. Page with NextCursor until it is empty: paging never repeats or skips a row.

A row has its status at the time of the read. For a statement, end the window a few minutes in the past, and read again later to pick up changes such as refunds. A key bound to a storefront lists that storefront only.

The key needs the transactions:read scope. A key with the payments scope cannot hold it, so use a separate reporting key. The call is signed on the path only, with an empty idempotency key and an empty body: the query string is not part of the signature.

Errors it returns:

  • *ValidationError: an empty From or To; nothing was sent.
  • *APIError: 400 VALIDATION_ERROR for a window, settlement window, status, limit or cursor the API refused. Raw carries error.validationErrors, one per field. Do not retry it unchanged.
  • *AuthError: also 403 INSUFFICIENT_SCOPE for a key without transactions:read.
  • *RateLimitError, *TransportError: as everywhere else.

func (*Client) ListWebhookEndpoints added in v0.5.0

func (c *Client) ListWebhookEndpoints(ctx context.Context) ([]WebhookEndpoint, error)

ListWebhookEndpoints lists your webhook endpoints, newest first: every active endpoint and your newest 1000 inactive ones. The list has no pages and never carries a secret. The key needs the webhooks:manage scope.

Errors it returns: as CreateWebhookEndpoint, without the validation and idempotency rules.

func (*Client) Ping added in v0.1.2

func (c *Client) Ping(ctx context.Context) (*Ping, error)

Ping verifies your credentials, your signing and your clock without creating anything. Make this your first live call: it isolates the setup problems from the payment ones, so a failure here is the key id, the secret, the signature, the clock or an IP allowlist and nothing else.

Check Ping.ClockSkewSeconds. Requests start failing at 300.

Errors it returns:

  • *AuthError: wrong credentials, bad signature, clock off, IP not allowlisted.
  • *APIError: an unexpected or rejecting response; inspect HTTPStatus.
  • *RateLimitError: HTTP 429; back off, the SDK never retries it for you.
  • *TransportError: network failure or 5xx.

func (*Client) RevokePaymentMethod added in v0.3.0

func (c *Client) RevokePaymentMethod(ctx context.Context, paymentMethodID string) error

RevokePaymentMethod revokes a card kept on file. The saved credential is deleted at the payment provider and the method's status becomes StoredPaymentMethodStatusRevoked; a later ChargePaymentMethod on it is refused with PAYMENT_METHOD_NOT_ACTIVE. Returns nil on success (HTTP 204), and again for an already revoked method, so retrying a timed-out revoke is safe. Not a payment operation: no idempotency key is signed.

Errors it returns:

  • *RevokeError: the gateway refused and nothing changed; MERCHANT_API_UNAVAILABLE (503, retry later) or UPSTREAM_CONTRACT_ERROR (502, the provider refused for good - contact support with the id).
  • *APIError: 404 for an id that is not yours, or an unexpected response.
  • *AuthError, *RateLimitError, *TransportError: as everywhere else.

func (Client) String added in v0.2.0

func (c Client) String() string

String keeps the secret out of your logs. Printing a Client with %v, %+v or %#v (value or pointer) shows the key id and the base URL, never the secret.

func (*Client) UpdatePlan added in v0.5.0

func (c *Client) UpdatePlan(ctx context.Context, planID string, params UpdatePlanParams) (*Plan, error)

UpdatePlan changes a plan's name, its trial for agreements created from now on, or its cancel page, and returns the plan. A nil field keeps its value. Amount, currency and interval never change.

The route takes no idempotency key, and the SDK sends none: the same update twice leaves the same plan.

Errors it returns: as GetPlan. A field the API refused is an *APIError with HTTPStatus 400.

type Consent struct {
	// TextVersion is the version of the consent text the customer accepted,
	// at most 50 characters. On a subscription session it must be a version
	// of the platform text, e.g. ConsentTextVersionRecurring202609.
	TextVersion string `json:"textVersion,omitempty"`
	// AcceptedAt is the ISO 8601 UTC instant the customer accepted it, e.g.
	// "2026-10-01T09:30:00Z". Not in the future.
	AcceptedAt string `json:"acceptedAt,omitempty"`
	// IP is the IPv4 or IPv6 address the customer accepted it from.
	IP string `json:"ip,omitempty"`
}

Consent records that your customer accepted recurring payment terms: which version of the text, when, and from which IP address. Send all three together.

type CreateAgreementParams added in v0.5.0

type CreateAgreementParams struct {
	// PlanID is the plan_ id of one of your active plans.
	PlanID string `json:"planId"`
	// CustomerReference is your own id for the customer, 1 to 100
	// characters, stable per customer.
	CustomerReference string `json:"customerReference"`
	// StoredPaymentMethodID is the pm_ id of an active card this customer
	// already saved, to bill it instead of saving a new card in the
	// subscription session. The card must have been saved from a payment in
	// the plan's currency. Consent is required with it.
	StoredPaymentMethodID string `json:"storedPaymentMethodId,omitempty"`
	// Consent is the customer's acceptance of your recurring terms: your own
	// label for the wording as TextVersion, when, and from which IP address.
	// It is required with StoredPaymentMethodID or MaxAmountPerPeriod.
	// Without them, leave it nil and let the subscription session capture it.
	Consent *Consent `json:"consent,omitempty"`
	// MaxAmountPerPeriod is for a variable-amount subscription only: the most
	// one charge may be, in MINOR units, at least the plan's amount. Consent
	// is required with it. 0 sends nothing: a fixed agreement, where every
	// charge is exactly the plan's amount.
	MaxAmountPerPeriod int64 `json:"maxAmountPerPeriod,omitempty"`

	// IdempotencyKey is required. It is sent as the Idempotency-Key header and
	// signed. Derive it from your own subscription record, never per attempt.
	IdempotencyKey string `json:"-"`
	// Extra holds request fields this struct does not model yet. They are
	// sent as given, and a key in Extra replaces the modelled field of the
	// same name.
	Extra map[string]any `json:"-"`
}

CreateAgreementParams is what CreateAgreement sends.

func (CreateAgreementParams) MarshalJSON added in v0.5.0

func (p CreateAgreementParams) MarshalJSON() ([]byte, error)

MarshalJSON writes the modelled fields, then the Extra fields.

type CreateCheckoutSessionParams

type CreateCheckoutSessionParams struct {
	// Amount is in MINOR units: 2500 is 25.00 EUR. Integers only. It must be
	// positive, except with VerifyOnly, where it must be 0: the platform sets
	// the verification amount.
	Amount int64 `json:"amount"`
	// Currency is ISO 4217, e.g. "EUR".
	Currency string `json:"currency"`
	// OrderReference is your own order id, at most 100 characters. It shows up
	// in your dashboard.
	OrderReference string `json:"orderReference"`

	Customer *Customer `json:"customer,omitempty"`
	// Country is ISO 3166-1 alpha-2.
	Country string `json:"country,omitempty"`
	// Language is the ISO 639-1 widget UI language.
	Language string `json:"language,omitempty"`
	// Theme is "light", "dark" or "bright".
	Theme       string `json:"theme,omitempty"`
	Description string `json:"description,omitempty"`

	// SaveCard asks the gateway to keep the card on file once this payment is
	// approved, so you can charge it again later with Client.ChargePaymentMethod.
	// The stored method shows up on CheckoutStatus.StoredPaymentMethod after the
	// payment succeeds; a declined first payment stores nothing. The card details
	// themselves never reach you: you get an id, a brand and the last four digits.
	// With VerifyOnly or AgreementID the card is always saved.
	SaveCard bool `json:"saveCard,omitempty"`

	// Integration picks how the payer enters the card: IntegrationWidget (the
	// default when empty) or IntegrationFields, card fields in your own page.
	// Card fields are enabled per merchant on request; asking for them on an
	// account without them is a 400 INVALID_SELECTION *APIError. It is part of
	// the idempotency identity: replaying a key with a different Integration is
	// IDEMPOTENCY_KEY_REUSED. Empty sends no integration field.
	Integration Integration `json:"integration,omitempty"`

	// LocationID is the id of the storefront (online location) this payment
	// belongs to. Empty uses your default storefront, or your only active one
	// when no default is set. An id that is not one of your storefronts is
	// refused with a 404 *APIError. An API key bound to one storefront refuses
	// another id with ErrorCodeStorefrontMismatch.
	LocationID string `json:"locationId,omitempty"`

	// VerifyOnly saves the payer's card without a payment. The checkout places
	// a small authorization to check the card and saves the card. The platform
	// releases the authorization within about 15 minutes, so nobody is
	// charged. Leave Amount 0. SaveCard is implied, Integration must be empty
	// or IntegrationWidget, and AgreementID must be empty.
	//
	// Verify-only sessions are off until we switch them on for your account.
	// Until then the session is refused with a 403 *AuthError, ErrorCode
	// ErrorCodeVerifyOnlyNotEnabled. The status read of a verify-only session
	// has VerifyOnly true, and a non-nil StoredPaymentMethod says the card is
	// verified. It fires verification.* webhook events, never payment.* ones.
	VerifyOnly bool `json:"verifyOnly,omitempty"`

	// AgreementID is the agr_ id of a PENDING agreement (CreateAgreement). It
	// makes this the subscription session: it takes the agreement's first
	// payment, saves the card and activates the agreement. Amount and Currency
	// must be the agreement's own; on a plan with a trial only the currency is
	// held. A refusal arrives as a *RefusalError with one of the
	// SubscriptionSessionRefusalCodes.
	AgreementID string `json:"agreementId,omitempty"`

	// Consent records that the payer accepted the platform's recurring payment
	// terms on your page. Send it with AgreementID only. Consent.TextVersion
	// must be a version of the platform text, e.g.
	// ConsentTextVersionRecurring202609.
	Consent *Consent `json:"consent,omitempty"`

	// IdempotencyKey is required. Derive it from the order with
	// OrderIdempotencyKey: the same order at the same amount must produce the
	// same key, so a reload, a Back button or a retry after a timeout replays
	// the open session instead of opening a second payment. It travels in the
	// header and in the signature, never in the body. 1 to 100 visible ASCII
	// characters; anything else is refused with a *ValidationError before
	// anything is sent.
	IdempotencyKey string `json:"-"`

	// Extra carries any additional field the API accepts that this struct does
	// not model yet. Keys here are merged into the JSON body.
	Extra map[string]any `json:"-"`
}

CreateCheckoutSessionParams are the parameters for Client.CreateCheckoutSession. Amount, Currency and OrderReference are required. With VerifyOnly, leave Amount 0.

func (CreateCheckoutSessionParams) MarshalJSON

func (p CreateCheckoutSessionParams) MarshalJSON() ([]byte, error)

MarshalJSON emits the request body: the modelled fields in declaration order, with Extra merged in. IdempotencyKey is excluded; it is a header, not a body field.

type CreatePlanParams added in v0.5.0

type CreatePlanParams struct {
	// Name is the display name, 1 to 100 characters. UpdatePlan can change it.
	Name string `json:"name"`
	// Amount is the charge per period, in MINOR units of Currency (2500 is
	// 25.00 EUR). Convert with ToMinorUnits.
	Amount int64 `json:"amount"`
	// Currency is the ISO 4217 code, three upper-case letters.
	Currency string `json:"currency"`
	// IntervalUnit is one of the IntervalUnit* values.
	IntervalUnit string `json:"intervalUnit"`
	// IntervalCount is the number of units per billing period: every 3 months
	// is IntervalUnitMonth with IntervalCount 3. 0 sends nothing, and the API
	// then uses 1.
	IntervalCount int `json:"intervalCount,omitempty"`
	// PeriodCount is the number of periods after which an agreement completes
	// on its own, at least 1. nil bills until the agreement is cancelled. It
	// is a pointer so that a count that computed to zero is refused instead
	// of turning into "bill forever".
	PeriodCount *int `json:"periodCount,omitempty"`
	// TrialDays is the number of days between activation and the first
	// charge, 0 to 365. 0 is no trial. UpdatePlan can change it for agreements
	// created later.
	TrialDays int `json:"trialDays,omitempty"`
	// CancelURL is your page where the customer cancels the subscription: an
	// absolute https URL. The platform prints it as the cancel link in every
	// receipt and decline notice it emails your customer. Empty sends nothing.
	CancelURL string `json:"cancelUrl,omitempty"`

	// IdempotencyKey is required. It is sent as the Idempotency-Key header and
	// signed. Derive it from your own plan record, never per attempt.
	IdempotencyKey string `json:"-"`
	// Extra holds request fields this struct does not model yet. They are
	// sent as given, and a key in Extra replaces the modelled field of the
	// same name.
	Extra map[string]any `json:"-"`
}

CreatePlanParams is what CreatePlan sends. Amount, Currency and the interval never change once the plan exists: to change a price, create a new plan and archive the old one.

func (CreatePlanParams) MarshalJSON added in v0.5.0

func (p CreatePlanParams) MarshalJSON() ([]byte, error)

MarshalJSON writes the modelled fields, then the Extra fields.

type CreateRefundParams added in v0.3.1

type CreateRefundParams struct {
	// Amount is what to refund, in MINOR units of the payment's currency (2500
	// is 25.00 EUR, 1500 is 1,500 HUF), at least 1. nil refunds everything still
	// refundable: no amount is sent at all. A pointer, so a partial refund that
	// computed to zero is refused as a *ValidationError instead of quietly
	// turning into a full refund. ToMinorUnits converts a decimal amount.
	Amount *int64 `json:"amount,omitempty"`
	// Reason is free text stored with the refund, at most 500 characters.
	Reason string `json:"reason,omitempty"`

	// IdempotencyKey is required. Derive it from YOUR refund (the return or
	// credit-note id), never per attempt: the same key always names the same
	// refund and never refunds twice, so on a timeout retry with the same key.
	// It travels in the header and in the signature, never in the body. 1 to
	// 100 visible ASCII characters; anything else is refused with a
	// *ValidationError before anything is sent.
	IdempotencyKey string `json:"-"`

	// Extra carries any additional field the API accepts that this struct does
	// not model yet. Keys here are merged into the JSON body and replace a
	// modelled field of the same name.
	Extra map[string]any `json:"-"`
}

CreateRefundParams are the parameters for Client.CreateRefund. Only IdempotencyKey is required.

func (CreateRefundParams) MarshalJSON added in v0.5.0

func (p CreateRefundParams) MarshalJSON() ([]byte, error)

MarshalJSON emits the request body: the modelled fields, with Extra merged in. A nil Amount is left out. IdempotencyKey is excluded; it is a header, not a body field.

type CreateWebhookEndpointParams added in v0.5.0

type CreateWebhookEndpointParams struct {
	// URL receives the deliveries: https on a public host, at most 500
	// characters.
	URL string `json:"url"`
	// Events are the event types to deliver, 1 to 50 entries: the Event*
	// constants. Duplicates count once.
	Events []string `json:"events"`

	// IdempotencyKey is required. It is sent as the Idempotency-Key header and
	// signed. Use a fresh key for each new endpoint.
	IdempotencyKey string `json:"-"`
	// Extra holds request fields this struct does not model yet. They are
	// sent as given, and a key in Extra replaces the modelled field of the
	// same name.
	Extra map[string]any `json:"-"`
}

CreateWebhookEndpointParams is what CreateWebhookEndpoint sends.

func (CreateWebhookEndpointParams) MarshalJSON added in v0.5.0

func (p CreateWebhookEndpointParams) MarshalJSON() ([]byte, error)

MarshalJSON writes the modelled fields, then the Extra fields.

type Customer

type Customer struct {
	FirstName string `json:"firstName,omitempty"`
	LastName  string `json:"lastName,omitempty"`
	Email     string `json:"email,omitempty"`
	Phone     string `json:"phone,omitempty"`
	// Address is the billing street address, one line, at most 200
	// characters. The widget asks the payer for address, city and postal
	// code when the payment processor needs them, and skips a field you send.
	Address string `json:"address,omitempty"`
	// City is the billing city, at most 100 characters.
	City string `json:"city,omitempty"`
	// PostalCode is the billing postal code, at most 20 characters.
	PostalCode string `json:"postalCode,omitempty"`
	// State is the billing state or province code, at most 50 characters,
	// e.g. "CA" or "NSW". Only used for the United States, Canada and
	// Australia.
	State string `json:"state,omitempty"`
}

Customer holds optional payer details. Prefilled fields are hidden from the payer in the widget, so the checkout form stays short.

type Error

type Error interface {
	error
	// contains filtered or unexported methods
}

Error is implemented by every error this SDK returns. It is sealed: only the types in this package satisfy it, so a type switch over them is exhaustive.

var apiErr dominaite.Error
if errors.As(err, &apiErr) { ... }

type Integration added in v0.4.0

type Integration string

Integration is how the payer enters the card on a checkout session.

const (
	// IntegrationWidget is the hosted cashier widget. The default.
	IntegrationWidget Integration = "widget"
	// IntegrationFields is card fields rendered in your own page by the
	// checkout.js drop-in. Enabled per merchant on request.
	IntegrationFields Integration = "fields"
)

Integration values, in the contract's order.

type IntegrationContract added in v0.5.0

type IntegrationContract struct {
	// Version is the dated version of the contract, e.g. "2026-09-16".
	Version string `json:"version"`
	// Statuses is the transaction status vocabulary, in the gateway's order.
	Statuses []string `json:"statuses"`
	// WebhookEventCatalog is the payment.* webhook event types. The recurring
	// and verification event types are in their own sections of Raw.
	WebhookEventCatalog []string `json:"webhookEventCatalog"`

	// RequestID is the API's id of the call that returned this: the
	// Dominaite-Request-Id response header, or metadata.requestId in the body
	// when the header is absent. Quote it to support. Empty when the API sent
	// neither.
	RequestID string `json:"-"`

	// Raw is the whole contract as the API sent it.
	Raw json.RawMessage `json:"-"`
}

IntegrationContract is what GetIntegrationContract returns: the API's own description of its routes, fields, codes and vocabularies.

type ListTransactionsParams added in v0.5.0

type ListTransactionsParams struct {
	// From is the start of the window, inclusive: ISO 8601 UTC in the Z form,
	// e.g. "2026-10-01T00:00:00Z" (t.UTC().Format(time.RFC3339)), or a date,
	// which is midnight UTC. Required.
	From string
	// To is the end of the window, exclusive, in the same form. Required. It
	// must be later than From, at most 31 days after it.
	To string
	// SettledFrom is the start of the settlement window, inclusive: a date in
	// the form "2026-10-13". Optional, but send it together with SettledTo.
	// With it, the list has only payments the acquirer settled in the window.
	// From and To still apply on CreatedAt.
	SettledFrom string
	// SettledTo is the end of the settlement window, exclusive, a date in the
	// same form. It must be later than SettledFrom, at most 31 days after it.
	SettledTo string
	// Status keeps only payments now in this status: one of Statuses. Empty
	// lists every status. The SDK does not check it: the API refuses an
	// unknown status with a 400.
	Status string
	// Limit is the number of items per page, 1 to 100. 0 sends nothing, and
	// the API then uses 50.
	Limit int
	// Cursor is the NextCursor of the previous page, unchanged. Empty reads
	// the first page.
	Cursor string
}

ListTransactionsParams selects one page of ListTransactions.

type Option

type Option func(*Client)

Option configures a Client. Pass options to New.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL sets the API the client calls: SandboxBaseURL, ProductionBaseURL or your own value. It is required: New returns a *ValidationError without it, and when the value is empty or whitespace. Whitespace and trailing slashes are trimmed.

The URL must be https://. Plain http:// is accepted only for localhost, 127.0.0.1 and ::1, so a local mock server still works; anything else is refused by New with a *ValidationError. Over http:// your API secret's signature, key id and the whole request body travel in clear text.

func WithHTTPClient

func WithHTTPClient(httpClient *http.Client) Option

WithHTTPClient supplies your own *http.Client: a proxy-aware transport, custom TLS, or a test double. It replaces WithTimeout, so set the timeout on the client you pass.

The client is shallow-copied, so the SDK can pin its own redirect policy without changing the behaviour of the client you keep using elsewhere.

func WithLogger added in v0.6.0

func WithLogger(logger *slog.Logger) Option

WithLogger sends the SDK's warnings to logger instead of slog.Default(), and turns on the debug log: one line per request at slog.LevelDebug, which your handler's level then shows or drops. The default logger never gets debug lines.

Warnings are logged at slog.LevelWarn: an SDK notice from the API (once per distinct value), each retry of the client's retry policy, and a Ping clock skew above 60 seconds. No line ever holds the secret, the signature, the cashier token, a query string or a request or response body.

func WithMaxRetries added in v0.7.0

func WithMaxRetries(maxRetries int) Option

WithMaxRetries sets how many times the client retries a failed call after the first attempt. Defaults to 2, so a call is sent at most 3 times. 0 disables retries. A negative value makes New return a *ValidationError.

The client retries reads (every GET) and every POST that carries an idempotency key, except CreateWebhookEndpoint, which is never retried. It retries only a *TransportError (a network failure or a 5xx) and PAYMENT_PROCESSING_UNAVAILABLE, on the refusal and on the charge error. The charge route's 503 PAYMENT_METHOD_CHARGES_DISABLED is not retried: it lasts until saved-card charges are switched on for your account. Every retry sends the same idempotency key and the same body, signed again with a fresh timestamp. A 429 is never retried. Neither is a DELETE or a PATCH.

The wait before retry n (0 for the first) is a random time between 0 and 500ms * 2^n, capped at 8 seconds. The timeout applies to each attempt, so one call can take up to (maxRetries + 1) times the timeout plus those waits. Bound the whole call with a context deadline when that matters.

func WithTimeout

func WithTimeout(timeout time.Duration) Option

WithTimeout sets the per-request timeout on the default HTTP client. Defaults to 45 seconds. A context deadline still wins when it is shorter.

func WithUserAgent

func WithUserAgent(userAgent string) Option

WithUserAgent appends your own identifier to the SDK's User-Agent, which helps when Dominaite support reads the access logs for your integration. The SDK sends "dominaite-go/<version> (go <go version>) <your suffix>". A suffix with a control character makes New return a *ValidationError.

type PaymentMethodCharge added in v0.3.0

type PaymentMethodCharge struct {
	// ChargeID is ch_ followed by 32 hex characters. Store it against the
	// order; it is what support asks for.
	ChargeID string `json:"chargeId"`
	// Status is one of the ChargeStatus* constants. Treat anything you do not
	// recognise as still open.
	Status string `json:"status"`
	// DeclineClass is one of the DeclineClass* constants on a failed charge. It
	// can be empty, for example when nobody declined the payment. An empty
	// class is not hard: if you retry, retry as for DeclineClassSoftOther. The
	// status read (GetStatus on TransactionID) carries the latest class. Empty
	// on every status other than failed.
	DeclineClass string `json:"declineClass,omitempty"`
	// DeclineCode is the issuer's decline code, verbatim, for your logs;
	// branch on DeclineClass instead. Empty unless the charge failed.
	DeclineCode string `json:"declineCode,omitempty"`
	// TransactionID is the transaction the charge created; readable with
	// GetStatus.
	TransactionID string `json:"transactionId"`
	// AgreementID is the agr_ id the charge was placed under, when the request
	// named one. Empty otherwise.
	AgreementID string `json:"agreementId,omitempty"`
	// Sequence is the charge's outcome counter as of this answer: on a charge
	// decided at once, the data.sequence of its charge.succeeded or
	// charge.failed event. 0 while pending. A replay returns the first answer
	// unchanged, so it can be lower than a later event's; GetStatus carries
	// the live value.
	Sequence int64 `json:"sequence,omitempty"`

	// RequestID is the API's id of the call that returned this: the
	// Dominaite-Request-Id response header, or metadata.requestId in the body
	// when the header is absent. Quote it to support. Empty when the API sent
	// neither.
	RequestID string `json:"-"`

	// Raw is the unparsed charge object, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

PaymentMethodCharge is what ChargePaymentMethod returns, for a placed charge (HTTP 201) and for a provider decline (HTTP 402, Status ChargeStatusFailed) alike. Also carried on a *ChargeError when the gateway attached the charge row to its answer.

type Ping added in v0.1.2

type Ping struct {
	// Pong is always true on a 200.
	Pong bool `json:"pong"`
	// MerchantID is the merchant your key authenticated as.
	MerchantID string `json:"merchantId"`
	// ServerTime is ISO 8601.
	ServerTime string `json:"serverTime,omitempty"`
	// ServerUnixTime is the server clock in unix seconds.
	ServerUnixTime int64 `json:"serverUnixTime,omitempty"`
	// ClockSkewSeconds is server time minus your X-Timestamp. If its absolute
	// value creeps toward 300, fix NTP now - requests start failing at 300.
	ClockSkewSeconds int64 `json:"clockSkewSeconds"`

	// RequestID is the API's id of the call that returned this: the
	// Dominaite-Request-Id response header, or metadata.requestId in the body
	// when the header is absent. Quote it to support. Empty when the API sent
	// neither.
	RequestID string `json:"-"`

	// Raw is the unparsed payload, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

Ping is what Ping returns: proof that your key, secret, signing and clock are all good, without creating anything.

type Plan added in v0.5.0

type Plan struct {
	// ID is plan_ followed by 32 hex characters. It is case-sensitive: store
	// it and send it back exactly as it is.
	ID   string `json:"id"`
	Name string `json:"name"`
	// Amount is the charge per period, in MINOR units of Currency.
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
	// IntervalUnit is one of the IntervalUnit* values.
	IntervalUnit  string `json:"intervalUnit"`
	IntervalCount int    `json:"intervalCount"`
	// PeriodCount is nil when agreements bill until they are cancelled.
	PeriodCount *int `json:"periodCount,omitempty"`
	// TrialDays applies to agreements created from now on. 0 is no trial.
	TrialDays int `json:"trialDays"`
	// Status is one of the PlanStatus* values.
	Status string `json:"status"`
	// CancelURL is empty when the plan has no cancel page.
	CancelURL string `json:"cancelUrl,omitempty"`
	// CreatedAt is ISO 8601 UTC.
	CreatedAt string `json:"createdAt"`
	// ArchivedAt is ISO 8601 UTC, empty while the plan is active.
	ArchivedAt string `json:"archivedAt,omitempty"`

	// RequestID is the API's id of the call that returned this plan. Quote it
	// to support. Empty when the API sent none.
	RequestID string `json:"-"`

	// Raw is the unparsed plan object, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

Plan is a subscription plan: a price and a billing interval you sell. An agreement subscribes one customer to it.

type RateLimitError added in v0.2.0

type RateLimitError struct {
	RetryAfterSeconds int
	HasRetryAfter     bool
	ErrorCode         string
	// Raw is the whole envelope the gateway sent, for fields not modelled
	// above. Empty when the 429 had no body.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

RateLimitError means the API answered HTTP 429: you sent requests faster than your allowance. Nothing was created and nothing is wrong with the request.

The platform limits are 60 requests per minute per API key and 120 per minute per IP address. Both are enforced, so several keys behind one egress IP share the second budget.

The SDK does NOT retry this for you, on purpose. A client that retries into a rate limit makes the queue longer for everyone, and only your code knows whether this payment can wait. The client's retry policy (WithMaxRetries) returns it at once. When you do retry, reuse the SAME idempotency key: the request may have been rejected at the edge, but that is not guaranteed.

RetryAfterSeconds carries the Retry-After header when the API sent one as integer seconds. HasRetryAfter distinguishes "wait 0 seconds" from "the API did not say" - a Retry-After in the HTTP-date form is reported as absent.

var limited *dominaite.RateLimitError
if errors.As(err, &limited) {
	wait := 5 * time.Second
	if limited.HasRetryAfter {
		wait = time.Duration(limited.RetryAfterSeconds) * time.Second
	}
	// reschedule with the same idempotency key
}

ErrorCode is the machine-readable code when the API named one, empty otherwise: ErrorCodeRateLimitExceeded for the request limits, and ErrorCodeChargeVelocityExceeded or ErrorCodeChargeDeclineRateExceeded on ChargePaymentMethod. Raw is the whole envelope; the charge limits put their window and limit under error.params.

func (RateLimitError) Error added in v0.2.0

func (e RateLimitError) Error() string

func (RateLimitError) Is added in v0.2.0

func (e RateLimitError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type Refund added in v0.3.1

type Refund struct {
	// RefundID is re_ followed by 32 hex characters. The same idempotency key
	// on the same payment always names the same refund.
	RefundID string `json:"refundId"`
	// TransactionID is the payment being refunded.
	TransactionID string `json:"transactionId"`
	// Status is one of the RefundStatus* constants.
	Status string `json:"status"`
	// Amount is in MINOR units. On pending it is the amount requested, nil for a
	// full refund; on processing it is the amount being refunded, nil until a
	// full refund has been sized; on succeeded it is the amount actually
	// refunded; on failed it is always nil.
	Amount *int64 `json:"amount,omitempty"`
	// Currency is the ISO 4217 code of the payment. A refund is always in the
	// payment's currency.
	Currency string `json:"currency"`
	// FailureCode is set on a failed refund only: one of the RefundFailure*
	// constants. Treat a value you do not recognise as RefundFailureFailed.
	FailureCode string `json:"failureCode,omitempty"`
	// FailureMessage is a fixed English explanation of FailureCode, for your
	// logs. Empty unless the refund failed.
	FailureMessage string `json:"failureMessage,omitempty"`
	// CompletedAt is the ISO 8601 UTC instant the refund reached succeeded or
	// failed. Empty before that.
	CompletedAt string `json:"completedAt,omitempty"`

	// RequestID is the API's id of the call that returned this: the
	// Dominaite-Request-Id response header, or metadata.requestId in the body
	// when the header is absent. Quote it to support. Empty when the API sent
	// neither.
	RequestID string `json:"-"`

	// Raw is the unparsed refund object, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

Refund is what CreateRefund and GetRefund return. The gateway omits null fields on the wire; they read as nil or empty here.

type RefundError added in v0.3.1

type RefundError struct {
	HTTPStatus int
	ErrorCode  string
	// Retryable reports whether retrying with the same idempotency key can
	// succeed: DUPLICATE_REQUEST and REFUND_NOT_FOUND.
	Retryable bool
	// Raw is the whole envelope the gateway sent, for fields not modelled above.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

RefundError means a refund route answered with one of the RefundError* codes instead of a refund: HTTP 400, 404, 409 or 422 (see each constant for what to do). HTTPStatus carries the status, Message the gateway's own text.

Retryable is true for the two codes the gateway asks you to retry with the SAME idempotency key: DUPLICATE_REQUEST (for up to 120 seconds) and, on GetRefund, REFUND_NOT_FOUND (for up to 60 seconds after CreateRefund). Everything else will not change on a retry.

refund, err := client.CreateRefund(ctx, transactionID, params)
var refundErr *dominaite.RefundError
if errors.As(err, &refundErr) && refundErr.ErrorCode == dominaite.RefundErrorAmountExceeded {
	// less is left to refund than you asked for; nothing was queued
}

A failed refund is not a *RefundError: it comes back as a Refund with Status RefundStatusFailed and a FailureCode. Authentication (401/403, *AuthError), rate limiting (429, *RateLimitError) and network failures or any 5xx (*TransportError: nothing was queued, retry with the SAME key; a 503 MERCHANT_API_UNAVAILABLE keeps its code on TransportError.ErrorCode) keep their generic errors on these routes.

func (RefundError) Error added in v0.3.1

func (e RefundError) Error() string

func (RefundError) Is added in v0.3.1

func (e RefundError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type RefundList added in v0.5.0

type RefundList struct {
	// TransactionID is the payment whose refunds these are.
	TransactionID string `json:"transactionId"`
	// Currency is the ISO 4217 code of the payment and of every amount here.
	Currency string `json:"currency"`
	// RefundedAmount is what was refunded so far, from every source: this
	// API, the dashboard and the provider.
	RefundedAmount int64 `json:"refundedAmount"`
	// RefundableAmount is what a new refund could take right now: what is not
	// refunded yet, minus refunds still in progress. 0 when the payment
	// cannot be refunded.
	RefundableAmount int64 `json:"refundableAmount"`
	// Refunds are the refunds created through this API: pending first, then
	// processing, then succeeded and failed, newest first within each group.
	// Empty when there are none.
	Refunds []Refund `json:"refunds"`

	// RequestID is the API's id of the call that returned this: the
	// Dominaite-Request-Id response header, or metadata.requestId in the body
	// when the header is absent. Quote it to support. Empty when the API sent
	// neither.
	RequestID string `json:"-"`

	// Raw is the unparsed refund list, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

RefundList is what ListRefunds returns: the refunds of one payment created through this API, and the amounts that decide the next refund. Every amount is in MINOR units of Currency.

type RefusalError

type RefusalError struct {
	ErrorCode string
	// TransactionID is the payment this idempotency key collided with, when the
	// API named one. Empty otherwise.
	TransactionID string
	// Raw is the unparsed refusal payload, for fields not modelled above.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

RefusalError means the gateway understood the request but refused to open a checkout session. The API answered with success: false.

Branch on ErrorCode (the ErrorCode* constants):

  • PAYMENT_PROCESSING_UNAVAILABLE: card payments are off right now; retry later.
  • DUPLICATE_REQUEST: a session for this idempotency key exists but cannot be handed back yet; re-POST the same key shortly.
  • ALREADY_PROCESSED: this idempotency key's payment already completed.
  • PRIOR_ATTEMPT_FAILED: a prior attempt with this key failed terminally; use a fresh key.
  • IDEMPOTENCY_KEY_REUSED: same key sent with a different amount, currency or card-saving choice; a re-priced order needs a new key.
  • STOREFRONT_MISMATCH: the key was first used for a different storefront.

On a replay refusal the API also names WHICH payment your key collided with, on TransactionID. That is the recovery path - read it back with GetStatus to find out what the earlier attempt did, instead of minting a second payment for the same order:

session, err := client.CreateCheckoutSession(ctx, params)
var refusal *dominaite.RefusalError
if errors.As(err, &refusal) && refusal.TransactionID != "" {
	status, err := client.GetStatus(ctx, refusal.TransactionID)
}

TransactionID is empty when the API did not name one - notably the concurrent-race DUPLICATE_REQUEST, which knows a key was taken but not yet by which row. Always check before using it.

Never blind-retry a refusal. It will not change on its own.

func (RefusalError) Error

func (e RefusalError) Error() string

func (RefusalError) Is

func (e RefusalError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type RetryOptions deprecated

type RetryOptions struct {
	// Attempts is the total number of attempts including the first. Zero means
	// the client's policy: WithMaxRetries plus the first attempt, 3 by default.
	Attempts int
	// BaseDelay bounds the wait before the first retry, which is a random time
	// between 0 and BaseDelay; the bound doubles each retry, up to 8 seconds.
	// Zero means the default of 500ms.
	BaseDelay time.Duration
}

RetryOptions tunes CreateCheckoutSessionWithRetry.

Deprecated: set the retry budget on the client with WithMaxRetries; CreateCheckoutSession retries on its own.

type RevokeError added in v0.3.0

type RevokeError struct {
	HTTPStatus int
	ErrorCode  string
	// Raw is the whole envelope the gateway sent, for fields not modelled above.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

RevokeError means the gateway refused to revoke a stored payment method: HTTP 502 or 503 with one of the RevokeError* codes on ErrorCode. Nothing changed either way. An id that is not yours is still the generic *APIError with HTTPStatus 404. Any other 5xx is a *TransportError, with the code when there was one.

func (RevokeError) Error added in v0.3.0

func (e RevokeError) Error() string

func (RevokeError) Is added in v0.3.0

func (e RevokeError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type SignInput

type SignInput struct {
	// Secret is your API secret (dms_...). json:"-" keeps it out of every
	// structured JSON logger: those encode the struct rather than calling
	// String, so a tag is the only thing that stops them.
	Secret string `json:"-"`
	// Timestamp is unix SECONDS, as sent in X-Timestamp.
	Timestamp string
	// Method is the HTTP method. Uppercased before signing.
	Method string
	// Path is the canonical path only: no host, no query string.
	Path string
	// IdempotencyKey is the Idempotency-Key header value. Empty string for GET.
	IdempotencyKey string
	// Body is the exact request body string that will be sent. Empty string for GET.
	Body string
}

SignInput is everything that goes into one request signature.

func (SignInput) GoString added in v0.2.0

func (in SignInput) GoString() string

GoString covers %#v, which does not use String.

func (SignInput) String added in v0.2.0

func (in SignInput) String() string

String keeps the secret out of your logs. Debugging an INVALID_SIGNATURE usually means printing the input that produced it, so printing a SignInput with %v, %+v or %#v (value or pointer) shows every field except the secret.

type StoredPaymentMethod added in v0.3.0

type StoredPaymentMethod struct {
	// ID is opaque: pm_ followed by 32 hex characters, case-sensitive. The
	// handle you charge and revoke with.
	ID string `json:"id"`
	// Brand is the card brand as the gateway reports it, e.g. "visa", "mastercard".
	Brand string `json:"brand,omitempty"`
	// Last4 is the last four digits of the card number, for display only.
	Last4 string `json:"last4,omitempty"`
	// ExpiryMonth is 1 to 12.
	ExpiryMonth int `json:"expiryMonth,omitempty"`
	// ExpiryYear is four digits, e.g. 2029.
	ExpiryYear int `json:"expiryYear,omitempty"`
	// Status is one of the StoredPaymentMethodStatus* constants. Treat any value
	// you do not recognise as not chargeable.
	Status string `json:"status"`
	// RetiredReason is one of the RetiredReason* constants when Status is
	// StoredPaymentMethodStatusRetired, and kept if the card is revoked
	// afterwards. Empty on every other card (the gateway sends null or omits it).
	RetiredReason string `json:"retiredReason,omitempty"`
}

StoredPaymentMethod is a card kept on file. Never the card number, never the PSP token - only what you may show a customer. Brand, Last4 and the expiry are zero when the provider did not report them (the gateway omits null fields on the wire).

type Transaction added in v0.5.0

type Transaction struct {
	// TransactionID is the payment's id, as the session or the charge
	// returned it.
	TransactionID string `json:"transactionId"`
	// OrderReference is your own order id from the session, empty when none
	// was sent.
	OrderReference string `json:"orderReference,omitempty"`
	// Type is one of the TransactionType* values.
	Type string `json:"type"`
	// Status is one of Statuses, the same value GetStatus answers.
	Status string `json:"status"`
	// Amount is in MINOR units of Currency.
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
	// CreatedAt is ISO 8601 UTC. The window and the order use it.
	CreatedAt string `json:"createdAt"`
	// CompletedAt is ISO 8601 UTC, when the payment first succeeded. Empty
	// when it never succeeded, and on older payments that succeeded before
	// this time was recorded.
	CompletedAt string `json:"completedAt,omitempty"`
	// StoredPaymentMethodID is the pm_ id of the card a charge was made on.
	// Empty on a checkout payment, and on an old charge that did not record
	// its card.
	StoredPaymentMethodID string `json:"storedPaymentMethodId,omitempty"`
	// RefundedAmount is what was refunded so far, in MINOR units. 0 when
	// nothing was refunded.
	RefundedAmount int64 `json:"refundedAmount,omitempty"`
	// Fee is the acquirer fee of this payment in MINOR units of Currency. nil
	// until the payment is settled, usually the next business day. It can
	// change, or go back to nil, when the settlement is matched again.
	Fee *int64 `json:"fee,omitempty"`
	// NetAmount is Amount minus Fee, in MINOR units. nil when Fee is nil. It
	// does not include deductions the acquirer makes on the whole payout.
	NetAmount *int64 `json:"netAmount,omitempty"`
	// SettledAt is the date the acquirer settled the payment, as ISO 8601 UTC
	// at midnight, e.g. "2026-10-13T00:00:00Z". Empty until settled.
	SettledAt string `json:"settledAt,omitempty"`
	// PayoutID is the id of the payout that paid this money to you, the id the
	// dashboard shows. Empty until the money is paid out.
	PayoutID string `json:"payoutId,omitempty"`

	// RequestID is the API's id of the call that read this page, the same as
	// TransactionList.RequestID. Quote it to support. Empty when the API sent
	// none.
	RequestID string `json:"-"`

	// Raw is the unparsed item, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

Transaction is one payment in ListTransactions. A refund is not a row of its own: it shows on its payment as RefundedAmount and Status.

type TransactionList added in v0.5.0

type TransactionList struct {
	// Items are the payments of this page, oldest first. Empty when the
	// window has none.
	Items []Transaction `json:"items"`
	// NextCursor reads the next page: send it back as Cursor with the same
	// From, To, SettledFrom, SettledTo and Status. Empty on the last page.
	NextCursor string `json:"nextCursor,omitempty"`

	// RequestID is the API's id of the call that read this page. Quote it to
	// support. Empty when the API sent none.
	RequestID string `json:"-"`

	// Raw is the unparsed page, for fields this struct does not model yet.
	Raw json.RawMessage `json:"-"`
}

TransactionList is one page of ListTransactions.

type TransportError

type TransportError struct {
	Err        error
	HTTPStatus int
	ErrorCode  string
	// Raw is the whole envelope the API sent with the 5xx, for fields not
	// modelled above.
	Raw json.RawMessage
	// contains filtered or unexported fields
}

TransportError means a network-level failure, a timeout, or a 5xx. The request may or may not have reached the API, so retry WITH THE SAME idempotency key: a retried key never creates a second payment. The client retries it for you on the keyed writes and the reads (see WithMaxRetries) before you see it.

Err is the underlying cause when there was one, reachable with errors.Unwrap or errors.As.

HTTPStatus is the 5xx status, 0 when no response arrived. ErrorCode is the API's code when the 5xx carried one, for example ErrorCodeMerchantAPIUnavailable or ErrorCodeInternalError, and empty otherwise. Raw is the envelope of that 5xx, nil when it was not JSON.

func (TransportError) Error

func (e TransportError) Error() string

func (TransportError) Is

func (e TransportError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

func (*TransportError) Unwrap

func (e *TransportError) Unwrap() error

Unwrap exposes the underlying network error, if any.

type UpdatePlanParams added in v0.5.0

type UpdatePlanParams struct {
	// Name is the new display name, 1 to 100 characters.
	Name *string `json:"name,omitempty"`
	// TrialDays is the new trial, 0 to 365 days, for agreements created from
	// now on. A pointer to 0 removes the trial.
	TrialDays *int `json:"trialDays,omitempty"`
	// CancelURL is the new cancel page, shown on the next email. A pointer to
	// "" removes it.
	CancelURL *string `json:"cancelUrl,omitempty"`

	// Extra holds request fields this struct does not model yet. They are
	// sent as given, and a key in Extra replaces the modelled field of the
	// same name.
	Extra map[string]any `json:"-"`
}

UpdatePlanParams is what UpdatePlan sends. A nil field keeps its value. Amount, currency and interval cannot change.

func (UpdatePlanParams) MarshalJSON added in v0.5.0

func (p UpdatePlanParams) MarshalJSON() ([]byte, error)

MarshalJSON writes the modelled fields, then the Extra fields.

type ValidationError

type ValidationError struct {
	// contains filtered or unexported fields
}

ValidationError means the SDK rejected the call before sending anything. Nothing reached the network; fix the arguments.

func (ValidationError) Error

func (e ValidationError) Error() string

func (ValidationError) Is

func (e ValidationError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

type VerifyOption added in v0.2.0

type VerifyOption func(*verifyConfig)

VerifyOption tunes VerifyWebhook. The defaults are correct for production; you should not need any of these outside tests.

func WithWebhookClock added in v0.2.0

func WithWebhookClock(now func() time.Time) VerifyOption

WithWebhookClock replaces the clock VerifyWebhook compares against. This exists so tests can pin a fixed instant against a recorded delivery; it has no use in production code. A nil function is ignored.

func WithWebhookTolerance added in v0.2.0

func WithWebhookTolerance(tolerance time.Duration) VerifyOption

WithWebhookTolerance overrides how far the delivery timestamp may sit from your clock. Defaults to DefaultWebhookTolerance (5 minutes).

Zero is the strictest setting, not an off switch: it requires the timestamp to equal your clock to the second, which in practice rejects everything that spent any time in flight. There is no way to disable the freshness check, because doing so accepts a replay of any delivery ever captured. A negative duration is refused outright - VerifyWebhook returns WebhookReasonInvalidTolerance without checking anything.

If you are reaching for this because deliveries keep failing, your server clock is wrong. Fix NTP rather than widening the window.

type WebhookData added in v0.2.0

type WebhookData struct {
	TransactionID string `json:"transactionId"`
	// OrderReference is your own order reference as stored on the
	// transaction: what you sent on create session, unless you replaced it
	// later. Match deliveries to your orders on it. Refund and cancel events
	// carry the original payment's. Nullable on the wire; empty string when
	// absent, which includes payments not started through the API.
	OrderReference string `json:"orderReference"`
	// OrderID is the hosted checkout order id, the same value as
	// CheckoutStatus.OrderID. Nullable on the wire; empty string on refund and
	// cancel events and for payments taken outside hosted checkout.
	OrderID string `json:"orderId"`
	// Description is the description you sent on create session. Nullable on
	// the wire; empty string when none was given and on refund and cancel
	// events.
	Description string `json:"description"`
	// Status is the wire status of the row, one of the Status* constants.
	Status string `json:"status"`
	// PreviousStatus is the status the row moved from. Nullable on the wire;
	// empty string when absent.
	PreviousStatus string `json:"previousStatus"`
	// Kind is the movement kind, e.g. "sale". Nullable on the wire; empty
	// string when absent.
	Kind string `json:"kind"`

	// Amount is in MINOR units. For payment.* it is what the merchant is PAID
	// (the base amount). For payment.refunded it is what was returned to the
	// customer. It is NOT the card movement - that is GrossAmount.
	Amount int64 `json:"amount"`
	// GrossAmount is the card movement in MINOR units: what the customer's card
	// was actually charged.
	GrossAmount int64 `json:"grossAmount"`
	// SurchargeAmount is in MINOR units and nil when no surcharge is known.
	// A pointer, so "no surcharge information" stays distinct from "zero".
	SurchargeAmount *int64 `json:"surchargeAmount"`
	// Currency is ISO 4217.
	Currency string `json:"currency"`

	// PSPReference is the payment processor's reference for the transaction,
	// on payment.* events. Empty (null or absent on the wire) until known, and
	// on a sale settled by reconciliation without a processor webhook. Refund
	// and cancel events carry the original sale's reference. charge.* events do
	// not have it. Older gateways omit it.
	PSPReference string `json:"pspReference"`

	// OriginalTransactionID is the parent transaction for refunds and
	// reversals. Empty on an original payment.
	OriginalTransactionID string `json:"originalTransactionId"`
	// IdempotencyKey is your own mint key when the gateway knows it - the
	// cheapest way to match a delivery back to your order without a lookup.
	// Empty when unknown, which today includes every refund.
	IdempotencyKey string `json:"idempotencyKey"`

	// PaymentMethod is how the payer paid, one of PaymentMethodCategories, on
	// payment.* and verification.* events: the same value as
	// CheckoutStatus.PaymentMethod. Nullable on the wire; empty string while
	// no method is known. On charge.* events the wire field is an object
	// instead: read it from Charge.PaymentMethod.
	PaymentMethod string `json:"paymentMethod"`
	// WalletType is which wallet, when PaymentMethod is PaymentMethodWallet. A
	// value outside WalletTypes is a valid wallet this SDK does not name yet.
	// Nullable on the wire; empty string for non-wallet payments.
	WalletType string `json:"walletType"`

	// PaymentMethodBrand is the lower-cased card brand (visa, mastercard, ...)
	// once a card payment has been attempted. Nullable on the wire; empty
	// string while the payment is open, for non-card methods, and when the
	// provider did not report a brand.
	PaymentMethodBrand string `json:"paymentMethodBrand"`
	// PaymentMethodLast4 is the card's last four digits once a card payment
	// has been attempted. Empty under the same conditions as
	// PaymentMethodBrand.
	PaymentMethodLast4 string `json:"paymentMethodLast4"`

	// StoredPaymentMethod is the card a SaveCard session stored, on payment.*
	// events: the same object as CheckoutStatus.StoredPaymentMethod, so its ID
	// is what ChargePaymentMethod takes. Set on payment.succeeded (and
	// payment.requires_capture for an authorization) when the card was stored
	// together with the approval.
	//
	// nil (null or absent on the wire) when no card was saved, and on every
	// other event. It can ALSO be nil when a card was saved: on server-to-server
	// sales that succeeded synchronously and on sales settled by the
	// background sweep, the card is stored after the approval was announced.
	// GetStatus is the source of truth: on a SaveCard session whose event has
	// StoredPaymentMethod nil, read the status to pick the card up.
	//
	// charge.* events carry the id instead, on Charge.StoredPaymentMethodID.
	StoredPaymentMethod *StoredPaymentMethod `json:"storedPaymentMethod,omitempty"`

	// VerifyOnly is true on every event of a verify-only session. False (or
	// absent on the wire) on every other event.
	VerifyOnly bool `json:"verifyOnly"`
	// DeclineClass is one of the DeclineClass* constants on payment.failed,
	// verification.failed and the charge.failed and charge.retrying events.
	// It can be empty, for example when nobody declined the payment. Empty
	// (null or absent on the wire) on every other event. A value outside
	// DeclineClasses is a class this SDK does not name yet, not an error.
	DeclineClass string `json:"declineClass"`

	// Sequence orders agreement.* and charge.* events for one object: it counts
	// the announced changes of that object, only ever rises, and a redelivery
	// carries the same number. The object is the agreement (Agreement.ID) for
	// agreement.*, the agreement period (Charge.AgreementID plus
	// Charge.PeriodNumber) for a charge the platform placed for an agreement,
	// and Charge.ChargeID for a one-off charge. Keep the highest Sequence you
	// have processed per object and discard any event whose Sequence is not
	// higher. Zero when absent (payment.* events do not carry it) and on
	// events recorded before the counter existed; treat zero as older than any
	// positive number.
	Sequence int64 `json:"sequence"`

	// Charge is the typed data of a charge.* event, nil on every other event,
	// and nil when the data does not fit ChargeWebhookData; read Raw then.
	// The fields above that a charge event shares (TransactionID, Amount,
	// Currency, OrderReference, Description, DeclineClass, Sequence) are set
	// too; Status is empty, because a charge event says Charge.Outcome.
	Charge *ChargeWebhookData `json:"-"`
	// Agreement is the typed data of an agreement.* event, nil on every other
	// event, and nil when the data does not fit AgreementWebhookData; read Raw
	// then. Status, PreviousStatus, Amount, Currency and Sequence above are
	// set too, with the agreement's values.
	Agreement *AgreementWebhookData `json:"-"`

	// Raw is the unparsed data object, for fields not modelled above.
	Raw json.RawMessage `json:"-"`
}

WebhookData is the payload of a WebhookEvent.

func (*WebhookData) UnmarshalJSON added in v0.7.0

func (d *WebhookData) UnmarshalJSON(raw []byte) error

UnmarshalJSON reads the data object of any event type. The paymentMethod field is a string on payment.* and verification.* events and an object on charge.* events; only the string lands on PaymentMethod.

type WebhookEndpoint added in v0.5.0

type WebhookEndpoint struct {
	// ID is the endpoint's UUID, lowercase.
	ID     string   `json:"id"`
	URL    string   `json:"url"`
	Events []string `json:"events"`
	// Status is one of the WebhookEndpointStatus* values.
	Status string `json:"status"`
	// CreatedAt is ISO 8601 UTC.
	CreatedAt string `json:"createdAt"`
	// Secret is the endpoint's signing secret (whsec_...), for VerifyWebhook.
	// Only the first answer to CreateWebhookEndpoint carries it. A replay of
	// the same key and ListWebhookEndpoints leave it empty, so store it when
	// you receive it. json:"-" keeps it out of structured JSON loggers, and
	// String keeps it out of %v.
	Secret string `json:"-"`

	// RequestID is the API's id of the call that returned this endpoint.
	// Quote it to support. Empty when the API sent none.
	RequestID string `json:"-"`

	// Raw is the unparsed endpoint object, for fields this struct does not
	// model yet. On the first answer to CreateWebhookEndpoint it holds the
	// secret too: never log it.
	Raw json.RawMessage `json:"-"`
}

WebhookEndpoint is one registered webhook endpoint.

func (WebhookEndpoint) GoString added in v0.5.0

func (e WebhookEndpoint) GoString() string

GoString covers %#v, which does not use String.

func (WebhookEndpoint) String added in v0.5.0

func (e WebhookEndpoint) String() string

String keeps the secret out of your logs: printing a WebhookEndpoint with %v, %+v or %#v (value or pointer) shows every field except the secret and Raw.

type WebhookEvent added in v0.2.0

type WebhookEvent struct {
	// ID is the delivery id and YOUR DEDUPE KEY. Delivery is at-least-once, so
	// the same ID can arrive more than once and you must ignore the repeats.
	ID string `json:"id"`
	// Type is one of the Event* constants. Treat an unrecognised type as a
	// no-op rather than an error; the catalog can grow.
	Type string `json:"type"`
	// APIVersion is the dated version of the payload shape the event was
	// rendered in, e.g. "2026-09-25". Fields are only ever added under a
	// version, never renamed or removed. A retry resends the bytes of the first
	// attempt, so a redelivered event keeps its original APIVersion. Empty on
	// deliveries from a gateway that predates the field.
	APIVersion string `json:"apiVersion"`
	// CreatedAt is the ISO 8601 UTC instant of the transition, not of delivery.
	// On a retry it still carries the original transition time. It can repeat
	// across events, so never order agreement.* or charge.* events by it; use
	// Data.Sequence.
	CreatedAt string      `json:"createdAt"`
	Data      WebhookData `json:"data"`

	// Raw is the exact verified payload, byte for byte as signed. Use it for
	// fields this struct does not model yet, or to store the delivery verbatim.
	Raw json.RawMessage `json:"-"`
}

WebhookEvent is one verified webhook delivery. VerifyWebhook returns it; there is no other way to get one, which is the point - the signature is checked before the body is parsed.

The envelope is FLAT. There is no ApiResponse wrapper and no `success` field, so do not branch on one.

func VerifyWebhook added in v0.2.0

func VerifyWebhook(payload []byte, signatureHeader, secret string, opts ...VerifyOption) (*WebhookEvent, error)

VerifyWebhook authenticates one webhook delivery and returns the parsed event. Verification happens BEFORE parsing, and the only way to obtain a WebhookEvent is through this function, so an unverified payload cannot reach your business logic by accident.

payload must be the RAW request body, exactly the bytes that arrived. Reading the body into a struct and re-encoding it will change them and the signature will not match. In net/http that means io.ReadAll(r.Body) before any json.Decode.

signatureHeader is the WebhookSignatureHeader value: "t={unix_seconds},v1={hex}". secret is the endpoint's whsec_ secret, shown once when the endpoint was created.

Every failure returns a *WebhookVerificationError carrying a WebhookReason* on .Reason. Nothing panics and no other error type comes out, so a malformed or hostile header is an ordinary rejection path:

body, err := io.ReadAll(r.Body)
if err != nil {
	w.WriteHeader(http.StatusBadRequest)
	return
}
event, err := dominaite.VerifyWebhook(body, r.Header.Get(dominaite.WebhookSignatureHeader), secret)
if err != nil {
	w.WriteHeader(http.StatusBadRequest)
	return
}
if !alreadySeen(event.ID) {   // delivery is at-least-once: dedupe on ID
	enqueue(event)            // do the work off the request path
}
w.WriteHeader(http.StatusOK)  // answer fast; a slow 2xx counts as a failure
Example

The doc example must stay compilable and correct.

event, err := VerifyWebhook(
	[]byte(webhookVector.Body),
	webhookVector.Header,
	webhookVector.Secret,
	atVectorTime(),
)
if err != nil {
	fmt.Println("rejected:", err)
	return
}
fmt.Println(event.Type, event.Data.Amount, event.Data.Currency)
Output:
payment.succeeded 8440 EUR

type WebhookVerificationError added in v0.2.0

type WebhookVerificationError struct {

	// Reason is one of the WebhookReason* constants.
	Reason string
	// contains filtered or unexported fields
}

WebhookVerificationError means VerifyWebhook rejected a delivery. Nothing was parsed and nothing should be acted on.

Answer the HTTP request with 400 and no detail. Never echo the reason back to the caller: whoever sent an unverified request does not get to learn whether their secret or their timestamp was the problem.

event, err := dominaite.VerifyWebhook(body, r.Header.Get("X-Webhook-Signature"), secret)
var verr *dominaite.WebhookVerificationError
if errors.As(err, &verr) {
	log.Printf("rejected webhook: %s", verr.Reason) // your logs only
	w.WriteHeader(http.StatusBadRequest)
	return
}

func (WebhookVerificationError) Error added in v0.2.0

func (e WebhookVerificationError) Error() string

func (WebhookVerificationError) Is added in v0.2.0

func (e WebhookVerificationError) Is(target error) bool

Is makes errors.Is(err, ErrDominaite) true for every SDK error.

Directories

Path Synopsis
examples
create-session command
Command create-session mints a Dominaite checkout session and reads its status back.
Command create-session mints a Dominaite checkout session and reads its status back.
webhook-handler command
Command webhook-handler is a runnable webhook receiver.
Command webhook-handler is a runnable webhook receiver.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL