Go library

The github.com/payitgo/go-sdk module wraps the payitgo API for Go: payments, tokens, your webhook (IPN) and its signature check, and the public checkout calls. It uses only the standard library. Use it on your server only: it holds your secret key.

Structs carry the API's fields with Go names (OrderID, CheckoutURL, …) and the API's JSON tags, so the Payments, Webhooks and Checkout pages describe every field this library returns.

Method HTTP Returns
client.Payments.Create(ctx, *PaymentParams) POST /v1/payments *Payment
client.Payments.Get(ctx, id) GET /v1/payments/{id} *Payment
client.Payments.List(ctx, *ListParams) GET /v1/payments *PaymentList
client.Payments.SelectToken(ctx, id, token) POST /v1/payments/{id}/token *Payment
client.Payments.Simulate(ctx, id, outcome) POST /v1/payments/{id}/simulate *Payment
client.Tokens.List(ctx, *TokenListParams) GET /v1/tokens []TokenInfo
client.Webhooks.Get(ctx) GET /v1/webhook *Webhook
client.Webhooks.Set(ctx, url) PUT /v1/webhook *Webhook
client.Webhooks.Remove(ctx) DELETE /v1/webhook *Webhook
client.Webhooks.Test(ctx) POST /v1/webhook/test *WebhookDelivery
client.Webhooks.ConstructEvent(body, header, secret...) none (local) *Event
payitgo.ConstructEvent(body, header, secret) none (local, no client) *Event
payitgo.ConstructEventWithTolerance(body, header, secret, tolerance) none (local, no client) *Event
client.Checkout.Get(ctx, checkoutToken) GET /checkout/{checkout_token} *Payment
client.Checkout.SelectToken(ctx, checkoutToken, token) POST /checkout/{checkout_token}/token *Payment
client.IsLive(ctx) GET /v1/webhook, the first time only bool

Every method that calls the API takes a context.Context and returns an error last. On error the returned pointer is not nil but holds no data: check err first.

Install

go get github.com/payitgo/go-sdk
import payitgo "github.com/payitgo/go-sdk"

The package is named payitgo; import it with that name, since the import path ends in go.

Requirement
Go 1.21 or later
Dependencies None (standard library only)

Quickstart

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	payitgo "github.com/payitgo/go-sdk"
)

func main() {
	client, err := payitgo.NewClient(os.Getenv("PAYITGO_KEY"), payitgo.WithBaseURL("https://api.payitgo.co"))
	if err != nil {
		log.Fatal(err)
	}
	payment, err := client.Payments.Create(context.Background(), &payitgo.PaymentParams{
		OrderID: "ORD-1001", AmountUSD: "49.90", Token: "USDT_TRC20",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(payment.CryptoAmount, payment.Token, "to", payment.DepositAddress)
}

Then set your webhook URL and fulfil the order when payment.confirmed arrives.

Configuration

NewClient

func NewClient(apiKey string, opts ...Option) (*Client, error)
Parameter Type Required Description
apiKey string yes Your secret key, test or live (Authentication)
opts ...Option no The options below
Option Default Description
WithBaseURL(u string) PAYITGO_BASE_URL, then the address the package was released with The gateway's address, https://api.payitgo.co. Trailing slashes are removed
WithHTTPClient(h *http.Client) &http.Client{Timeout: 30 * time.Second} The HTTP client every request goes through: your timeout, transport, proxy or tracing. Its Timeout is the limit for one attempt; a zero Timeout means none
WithMaxRetries(n int) 2 How many times a request is retried after a network error, a 429 or a 5xx. 0 turns retries off
WithWebhookSecret(secret string) none Your mode's signing secret. Webhooks.ConstructEvent uses it when you pass none
WithWebhookURL(u string) none Sent as webhook_url with every Payments.Create whose PaymentParams.WebhookURL is empty

NewClient makes no request. It returns an error when:

Error Cause
payitgo: the API key is required apiKey is empty
payitgo: set WithBaseURL (or PAYITGO_BASE_URL) to your gateway's address No address from the option, the environment or the package (WithBaseURL("") clears the others)
client, err := payitgo.NewClient(os.Getenv("PAYITGO_KEY"),
	payitgo.WithBaseURL("https://api.payitgo.co"), // or set PAYITGO_BASE_URL
	payitgo.WithHTTPClient(&http.Client{Timeout: 15 * time.Second}),
	payitgo.WithMaxRetries(2),
	payitgo.WithWebhookSecret(os.Getenv("PAYITGO_WEBHOOK_SECRET")), // the signing secret of the key's mode
	payitgo.WithWebhookURL("https://acme.example/payitgo/ipn"),     // optional default for Payments.Create
)
if err != nil {
	log.Fatal(err)
}

A *Client is safe for concurrent use: create one and share it.

The Client

type Client struct {
	Payments *PaymentsService
	Tokens   *TokensService
	Checkout *CheckoutService
	Webhooks *WebhooksService
	// unexported fields
}

payitgo.Version ("0.1.0") is sent in the User-Agent: payitgo-go/0.1.0 header.

IsLive

GET /v1/webhook · API reference

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

Returns true for a live key, false for a test key. A key does not say its mode, so the first call asks payitgo (the Livemode of the key's webhook; a read-only key may call it) and the answer is kept for the client's life: later calls make no request. Safe for concurrent use.

Errors a *payitgo.Error, as any call: 401 for a key payitgo does not know, 403 for a live key while live mode is off. An error is not kept: the next call asks again.

live, err := client.IsLive(ctx)
if err != nil {
	return err
}
if !live {
	log.Println("test mode: payments are simulated")
}

Base URL

The gateway address is taken from, in order: WithBaseURL, the PAYITGO_BASE_URL environment variable, and the address the package was released with:

export PAYITGO_BASE_URL=https://api.payitgo.co

Timeouts, cancellation and the HTTP client

Each request is sent with the client's *http.Client (30-second timeout by default; replace it with WithHTTPClient) and your ctx. The context bounds the whole call, retries and waits included; a cancelled or expired context stops it at once and is not retried. Requests carry the headers Authorization: Bearer <key>, Accept: application/json, Content-Type: application/json (when there is a body) and User-Agent: payitgo-go/0.1.0.

ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
payment, err := client.Payments.Get(ctx, "ORD-1001")
if err != nil {
	return err // a *payitgo.Error, or ctx.Err() once the 10 seconds are up
}

Test and live mode

The key decides the mode. A test key creates and reads test payments (no coins: you simulate what the customer sends) and manages the test webhook; a live key does the same for live ones. The key does not say which it is: the dashboard shows a Test or Live badge next to it, and client.IsLive(ctx) asks payitgo. Pair each key with the webhook secret of the same mode. See Test mode.

Errors

payitgo.Error

A request the API refused, or that got no answer, returns a *payitgo.Error:

type Error struct {
	Status  int    // the HTTP status; 0 when no answer came
	Message string // the API's error message
}

func (e *Error) Error() string // "payitgo: <Message> (HTTP <Status>)", or "payitgo: <Message>" when Status is 0
Field Description
Status The HTTP status, or 0 when no answer came (network error or timeout, after the retries)
Message The API's error message, such as minimum payment in USDT_TRC20 is $6.44 right now (network fees). The status text (such as Bad Gateway) when the answer had no message. request failed: <cause> or reading the response: <cause> when Status is 0

Read it with errors.As, branch on Status, and log Message (its wording may change, see Errors and limits):

Status Meaning Retried by the library
0 No answer: DNS, connection, TLS or timeout Yes
400 The request is invalid (the message says which field) No
401 missing api key or invalid api key No
403 Not allowed, for example a live key while live mode is off, or a storefront key changing the account's webhook No
404 No such payment in the key's mode No
409 Conflicts with the current state, such as choosing a payment's coin twice, or simulating a payment whose simulation is running No
429 Over the rate limit Yes
500 and other 5xx Something failed on payitgo's side Yes
_, err := client.Payments.Create(ctx, &payitgo.PaymentParams{OrderID: "ORD-1", AmountUSD: "1", Token: "BTC"})
var perr *payitgo.Error
if errors.As(err, &perr) && perr.Status == http.StatusBadRequest {
	log.Printf("payitgo refused: %s", perr.Message) // e.g. an amount under the coin's minimum
} else if err != nil {
	return err
}

Two other kinds of error can come back: ctx.Err() (context.Canceled or context.DeadlineExceeded) when the context ends while the library waits to retry, and an encoding/json error when a successful answer is not the JSON the method expects.

payitgo.ErrSignature

ConstructEvent returns an error wrapping payitgo.ErrSignature when a notification cannot be trusted. Check it with errors.Is and answer 400. Its text is payitgo: webhook signature verification failed: <reason>:

Reason Cause
missing or malformed X-Webhook-Signature header No header, or no t or v1 in it
signature does not match The body, the timestamp or the secret differ from what payitgo signed
malformed timestamp t is not a whole number of seconds (checked unless the tolerance is 0)
timestamp outside the tolerance t is further than the tolerance (5 minutes) from your clock
no webhook secret: pass one, or use WithWebhookSecret The client method got no secret and the client has no WithWebhookSecret
event, err := client.Webhooks.ConstructEvent(body, r.Header.Get("X-Webhook-Signature"))
if errors.Is(err, payitgo.ErrSignature) {
	http.Error(w, "bad signature", http.StatusBadRequest)
	return
}

A body that is correctly signed but is not JSON returns an encoding/json error instead; payitgo never sends one. Answer 400 to any error from ConstructEvent.

Retries

A request is retried, up to WithMaxRetries times (default 2, so at most 3 attempts), after a network error or timeout, a 429 or a 5xx. It waits 0.5 s before the first retry and 1 s before the second (500 ms × 2ⁿ), or less if ctx ends first. Other statuses are never retried. When the last attempt fails, the call returns a *payitgo.Error with that attempt's status (0, 429 or 5xx).

Every call is retried the same way, and each is safe to repeat:

Call Repeating it
Payments.Create Returns the order's open payment instead of a second one (safe retries)
Payments.SelectToken, Checkout.SelectToken If the first attempt went through, the repeat returns 409: read the payment with Get, it has its coin
Payments.Simulate If the first attempt went through, the repeat returns 409 while that simulation runs: read the payment with Get
Webhooks.Set, Webhooks.Remove Changes nothing the second time
Webhooks.Test Queues one more ping
Reads Change nothing

Payments

Payments.Create

POST /v1/payments · API reference

func (s *PaymentsService) Create(ctx context.Context, p *PaymentParams) (*Payment, error)

Creates a payment for one of your orders. With a Token, the payment comes back with its deposit address, amount, QR payload and a 15-minute deadline. Without one, it comes back with TokenOptions and the customer (or your page, with SelectToken) chooses the coin. While the order has an open payment, creating it again returns that payment.

p must not be nil. Its fields (PaymentParams):

Field JSON Type Required Description
OrderID order_id string yes Your id for the order, 1 to 128 bytes. One open payment per order and mode
AmountUSD amount_usd string yes, unless PriceAmount + PriceCurrency The price in US dollars, as a decimal string: "49.90"
PriceAmount price_amount string with PriceCurrency Instead of AmountUSD: the price in another currency, as a decimal string: "45.50". payitgo converts it into dollars at the rate of the moment (prices in other currencies)
PriceCurrency price_currency string with PriceAmount The ISO 4217 code of its currency, such as "EUR", "GBP" or "NGN" (any case)
Token token string no The coin, such as "USDT_TRC20" (from Tokens.List). Empty: the customer chooses
Tokens tokens []string no Without Token: the coins to offer, such as []string{"USDT_TRC20", "USDT_BEP20"}. Default: every coin you accept whose minimum the amount reaches
Description description string no What the customer pays for, up to 500 bytes
Metadata metadata map[string]string no Your own data: up to 20 keys of up to 40 bytes, values of up to 500
CustomerEmail customer_email string no The customer's email address
SuccessURL success_url string no http(s) URL the hosted checkout links to once the payment is confirmed
CancelURL cancel_url string no http(s) URL the hosted checkout links back to if the payment closes unpaid
WebhookURL webhook_url string no https:// URL that gets this payment's notifications instead of your account's webhook URL (per-payment URL). Empty: the client's WithWebhookURL, if any
Simulate simulate string no Test keys only: the outcome to simulate, a payitgo.Outcome… constant. With Token it starts at once; without, once the coin is chosen

Empty optional fields are left out of the request.

Returns the *Payment: the new one (201) or the order's open one (200); both look the same. Fields: the payment object.

Errors *payitgo.Error with Status 400 for an invalid request: a missing order_id, amount_usd must be > 0, a token you do not accept, an amount under the token's minimum, a webhook_url that is not public https, a simulate with a live key or an unknown outcome, a price in another currency given with amount_usd, without its pair or in a code that is no currency, and the rest of the create errors. 503 when no exchange rate can be had for price_currency (after the retries): try again shortly. Also 401, 403, 429, 500 and 0 as for every call.

// The customer pays in a coin you know:
payment, err := client.Payments.Create(ctx, &payitgo.PaymentParams{
	OrderID:       "ORD-1001",
	AmountUSD:     "49.90",
	Token:         "USDT_TRC20",
	Description:   "2 x Acme T-shirt",
	Metadata:      map[string]string{"customer_id": "cus_42"},
	CustomerEmail: "jane@example.com",
	SuccessURL:    "https://acme.example/thanks",
	CancelURL:     "https://acme.example/cart",
})
if err != nil {
	return err
}
// payment.DepositAddress, payment.CryptoAmount, payment.QRPayload, payment.ExpiresAt

// The customer chooses the coin (on the hosted page, or yours):
open, err := client.Payments.Create(ctx, &payitgo.PaymentParams{OrderID: "ORD-1002", AmountUSD: "25"})
if err != nil {
	return err
}
for _, t := range open.TokenOptions {
	fmt.Println(t.Token, t.MinUSD, t.AmountDueUSD)
}

// This payment's notifications go to its own URL:
_, err = client.Payments.Create(ctx, &payitgo.PaymentParams{
	OrderID: "ORD-1003", AmountUSD: "30", Token: "USDT_TRC20",
	WebhookURL: "https://acme.example/orders/ORD-1003/ipn",
})

// Priced in euros: converted into dollars at the rate of the moment.
priced, err := client.Payments.Create(ctx, &payitgo.PaymentParams{
	OrderID: "ORD-1004", PriceAmount: "45.50", PriceCurrency: "EUR",
})
// priced.PriceAmount "45.5", priced.PriceCurrency "EUR", priced.PriceRate "1.082", priced.USDAmount "49.23"

Payments.Get

GET /v1/payments/{id} · API reference

func (s *PaymentsService) Get(ctx context.Context, id string) (*Payment, error)
Parameter Type Required Description
id string yes The payment's id, or your order_id for the order's open payment (the payment id is tried first). Escaped for the URL by the library

Returns the *Payment.

Errors *payitgo.Error 404 (payment not found) for an unknown id, a payment of the other mode, or an order with no open payment. Plus the common statuses.

byID, err := client.Payments.Get(ctx, "8f3a2c1e9b7d4a6f05e1c2d3")
if err != nil {
	return err
}
byOrder, err := client.Payments.Get(ctx, "ORD-1001") // the order's open payment
if err != nil {
	return err
}
if byID.Status == payitgo.StatusConfirmed {
	fmt.Println("paid at", byID.ConfirmedAt, "order", byOrder.OrderID)
}

Payments.List

GET /v1/payments · API reference

func (s *PaymentsService) List(ctx context.Context, p *ListParams) (*PaymentList, error)

Payments of the key's mode, newest first. p may be nil. Its fields (ListParams), each left out of the query when it is the zero value:

Field Type Default Description
Status string all Only payments in this status: one of the payitgo.Status… constants
Chain string all Only payments on this network, such as "tron"
Token string all Only payments in this coin, such as "USDT_TRC20"
StoreID string all Only payments of this store (ignored for a storefront key, which sees its own)
Limit int 50 Page size, 1 to 200 (above 200 the API uses 50)
Offset int 0 How many to skip

Returns a *PaymentList: Payments, Total (matches across all pages), and the Limit and Offset applied.

Errors the common statuses only. An unknown status, chain or token matches nothing.

// Every confirmed payment, page by page:
var confirmed []payitgo.Payment
for offset := 0; ; {
	page, err := client.Payments.List(ctx, &payitgo.ListParams{Status: payitgo.StatusConfirmed, Limit: 100, Offset: offset})
	if err != nil {
		return err
	}
	confirmed = append(confirmed, page.Payments...)
	offset += page.Limit
	if offset >= page.Total {
		break
	}
}

Payments.SelectToken

POST /v1/payments/{id}/token · API reference

func (s *PaymentsService) SelectToken(ctx context.Context, id, token string) (*Payment, error)

Sets the coin of a payment created without one, once the customer picked it on your own page. The coin can be set once.

Parameter Type Required Description
id string yes The payment's id or your order_id
token string yes One of the payment's TokenOptions[].Token, such as "USDT_TRC20"

Returns the whole *Payment, now with DepositAddress, CryptoAmount, RateLocked, QRPayload and an ExpiresAt 15 minutes away.

Errors *payitgo.Error:

Status Message Cause
400 token is required Empty token
400 token is not one of this payment's options A coin not in TokenOptions
400 minimum payment in … is $… right now (it is converted on its way to the merchant, and the exchange takes no less) The coin needs a bigger payment than this one: it is converted through the exchange on its way to the merchant, which takes no less, and the merchant pays the fees or hides such coins (hide_below_minimum). Offer the other coins
400 this payment can no longer be paid The payment is no longer pending, or its hour to choose has passed
404 payment not found No such payment in the key's mode
409 the coin for this payment has already been chosen The coin was set before (by you or on the hosted page)
payment, err := client.Payments.Create(ctx, &payitgo.PaymentParams{OrderID: "ORD-1002", AmountUSD: "25"})
if err != nil {
	return err
}
// …your page lists payment.TokenOptions, the customer picks one…
chosen, err := client.Payments.SelectToken(ctx, payment.ID, "USDT_BEP20")
if err != nil {
	return err
}
fmt.Println(chosen.CryptoAmount, chosen.Token, chosen.DepositAddress, chosen.ExpiresAt)

Payments.Simulate

POST /v1/payments/{id}/simulate · API reference

func (s *PaymentsService) Simulate(ctx context.Context, id, outcome string) (*Payment, error)

Plays an outcome on a test payment: payitgo sends its deposits, status changes and webhooks about 5 seconds apart, as live coins would, with no coins (Test mode). Test keys only.

Parameter Type Required Description
id string yes The payment's id or your order_id
outcome string yes A payitgo.Outcome… constant (what each does)

Returns the whole *Payment, with its Simulation started. Simulated deposits have Simulated true; their TxID names no transaction on any chain.

Errors *payitgo.Error:

Status Message Cause
400 outcome is required Empty outcome
400 simulate must be one of: confirmed, overpaid, … An unknown outcome
400 only test payments can be simulated: use a test key A live key
400 choose the payment's coin first No coin yet, for anything but expired
400 this payment is confirmed: only a pending or partially paid payment can be simulated The payment is closed or confirming
400 a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain wrong_asset on a coin that has no other
404 payment not found No such payment in the key's mode
409 a simulation is already running for this payment Wait until its State is done
payment, err := client.Payments.Simulate(ctx, "ORD-1002", payitgo.OutcomePartiallyPaid)
if err != nil {
	return err
}
fmt.Println(payment.Simulation.Outcome, payment.Simulation.State) // partially_paid running
// once it is done, pay the rest:
payment, err = client.Payments.Simulate(ctx, "ORD-1002", payitgo.OutcomeConfirmed)

Tokens

Tokens.List

GET /v1/tokens · API reference

func (s *TokensService) List(ctx context.Context, p *TokenListParams) ([]TokenInfo, error)

The coins your customers can pay with in the key's mode, with today's network fee and minimum. With an amount, only the coins a payment of that amount offers (its token_options), each with AmountDueUSD when you pass the fees on.

Parameter Type Required Description
p *TokenListParams no nil for every coin, or &payitgo.TokenListParams{AmountUSD: "49.90"} for the coins of a payment of that amount

Returns a []TokenInfo (the library unwraps the API's {"tokens": […]}).

Errors *payitgo.Error 400 (amount_usd must be > 0) for an amount that is not a positive number, and the common statuses.

tokens, err := client.Tokens.List(ctx, &payitgo.TokenListParams{AmountUSD: "49.90"})
if err != nil {
	return err
}
for _, t := range tokens {
	fmt.Printf("%s on %s: pay $%s, %s\n", t.Token, t.Chain, t.AmountDueUSD, t.FeeNote)
}

Webhooks

The webhook (IPN) of the key's mode: one URL that receives every notification of that mode, and the secret that signs them (Webhooks). A test key manages the test webhook, a live key the live one. A storefront key can Get and Test it, but not Set or Remove it.

Webhooks.Get

GET /v1/webhook · API reference

func (s *WebhooksService) Get(ctx context.Context) (*Webhook, error)

Returns the *Webhook: URL ("" when none is set) and Secret, the signing secret to pass to WithWebhookSecret. It always succeeds for a valid key.

webhook, err := client.Webhooks.Get(ctx)
if err != nil {
	return err
}
fmt.Println(webhook.URL, webhook.Secret)

Webhooks.Set

PUT /v1/webhook · API reference

func (s *WebhooksService) Set(ctx context.Context, url string) (*Webhook, error)
Parameter Type Required Description
url string yes An absolute https:// URL on a public host that accepts POST

Returns the *Webhook with its new URL. The secret does not change. Setting the URL it already has changes nothing.

Errors *payitgo.Error 400 (url is required, url must use https, url must be an absolute http(s) URL, url host does not resolve, url must point at a public host), 403 for a storefront key, 500 (could not set the webhook), and the common statuses.

if _, err := client.Webhooks.Set(ctx, "https://acme.example/payitgo/ipn"); err != nil {
	return err
}

Webhooks.Remove

DELETE /v1/webhook · API reference

func (s *WebhooksService) Remove(ctx context.Context) (*Webhook, error)

Stops posting the mode's notifications to its URL. Payments created with their own webhook_url still get theirs, and the secret is kept.

Returns the *Webhook with URL "".

Errors 403 for a storefront key, 500 (could not remove the webhook), and the common statuses.

Webhooks.Test

POST /v1/webhook/test · API reference

func (s *WebhooksService) Test(ctx context.Context) (*WebhookDelivery, error)

Queues a signed ping notification to the mode's URL; it arrives within about 5 seconds.

Returns the *WebhookDelivery, with Status payitgo.DeliveryPending.

Errors *payitgo.Error 400 (no webhook url is set for this mode: set one first), 500 (could not queue the test), and the common statuses.

delivery, err := client.Webhooks.Test(ctx)
if err != nil {
	return err
}
fmt.Println(delivery.ID, delivery.EventType, delivery.URL) // 6710c3a1f2b4e5d6a7b8c9d0 ping https://…

Webhooks.ConstructEvent

No request · how verification works

func (s *WebhooksService) ConstructEvent(payload []byte, signatureHeader string, secret ...string) (*Event, error)

Verifies a notification with a 5-minute tolerance and returns its event. It computes the HMAC-SHA256 of t + "." + payload with the secret, compares it with v1 in constant time (hmac.Equal), checks that t is within 5 minutes of your clock, and only then parses the body.

Parameter Type Required Description
payload []byte yes The raw request body, exactly as received (io.ReadAll(r.Body)). A body decoded and encoded again will not verify (keep the raw body)
signatureHeader string yes r.Header.Get("X-Webhook-Signature")
secret string no The signing secret, as payitgo gave it. Default: the client's WithWebhookSecret. A non-empty secret passed here wins

For another tolerance, use payitgo.ConstructEventWithTolerance.

Returns the *Event.

Errors an error wrapping payitgo.ErrSignature with the reason, or an encoding/json error for a signed body that is not JSON. Answer 400.

event, err := client.Webhooks.ConstructEvent(body, r.Header.Get("X-Webhook-Signature"))
if err != nil {
	http.Error(w, "bad signature", http.StatusBadRequest)
	return
}
if event.Type == payitgo.EventPaymentConfirmed {
	p, err := event.Payment()
	if err == nil {
		fmt.Println(p.OrderID)
	}
}

ConstructEvent and ConstructEventWithTolerance

const DefaultTolerance = 5 * time.Minute

func ConstructEvent(payload []byte, signatureHeader, secret string) (*Event, error)
func ConstructEventWithTolerance(payload []byte, signatureHeader, secret string, tolerance time.Duration) (*Event, error)

The same check without a client, for code that has the secret but no API key. ConstructEvent uses DefaultTolerance. ConstructEventWithTolerance takes your own; 0 turns the timestamp check off (only for tests with captured bodies).

event, err := payitgo.ConstructEvent(body, r.Header.Get("X-Webhook-Signature"), os.Getenv("PAYITGO_WEBHOOK_SECRET"))
if err != nil {
	http.Error(w, "bad signature", http.StatusBadRequest)
	return
}

Errors an error wrapping payitgo.ErrSignature with the reason, or an encoding/json error for a signed body that is not JSON.

Checkout

These two calls use the public checkout API: they are sent without your API key and are keyed by the payment's checkout_token. They return the payer's view of a payment. On your server you rarely need them: Payments.Get and Payments.SelectToken return more. The client still needs a valid key to be created.

Checkout.Get

GET /checkout/{checkout_token} · API reference

func (s *CheckoutService) Get(ctx context.Context, checkoutToken string) (*Payment, error)
Parameter Type Required Description
checkoutToken string yes The payment's CheckoutToken

Returns a *Payment without your private fields: MerchantID, Metadata, CustomerEmail, WebhookURL, CheckoutToken and CheckoutURL are empty.

Errors *payitgo.Error 404 (payment not found), 0, 429 and 5xx.

Checkout.SelectToken

POST /checkout/{checkout_token}/token · API reference

func (s *CheckoutService) SelectToken(ctx context.Context, checkoutToken, token string) (*Payment, error)
Parameter Type Required Description
checkoutToken string yes The payment's CheckoutToken
token string yes One of the payment's TokenOptions[].Token

Returns the payer's view of the *Payment with its coin, address, amount and a 15-minute deadline.

Errors the same as Payments.SelectToken, and 429 (rate limit exceeded) beyond 5 choices a second from one IP address.

view, err := client.Checkout.Get(ctx, "b28d4f61e9a03c75d2e8f1a6")
if err != nil {
	return err
}
if view.Token == "" {
	chosen, err := client.Checkout.SelectToken(ctx, "b28d4f61e9a03c75d2e8f1a6", "USDT_TRC20")
	if err != nil {
		return err
	}
	fmt.Println(chosen.DepositAddress)
}

Types

Amounts are decimal strings ("49.9"): parse them with a decimal package, not float64. Times are time.Time, optional times *time.Time. Optional fields the API leaves out are the zero value ("", false, nil).

Payment

The payment object.

type Payment struct {
	ID                 string            `json:"id"`
	MerchantID         string            `json:"merchant_id,omitempty"`
	StoreID            string            `json:"store_id,omitempty"`
	OrderID            string            `json:"order_id"`
	Status             string            `json:"status"`     // a Status… constant
	Livemode           bool              `json:"livemode"`
	Simulation         *Simulation       `json:"simulation,omitempty"` // a test payment's simulated outcome
	Token              string            `json:"token"`      // "" until the coin is chosen
	Chain              string            `json:"chain"`      // "" until the coin is chosen
	TokenOptions       []TokenInfo       `json:"token_options,omitempty"` // only while Token is ""
	DepositAddress     string            `json:"deposit_address"`
	DestinationTag     *uint32           `json:"destination_tag,omitempty"` // XRP only
	CryptoAmount       string            `json:"crypto_amount"`
	ReceivedAmount     string            `json:"received_amount"`
	USDAmount          string            `json:"usd_amount"`
	PriceAmount        string            `json:"price_amount,omitempty"`   // your price in another currency, when you gave one
	PriceCurrency      string            `json:"price_currency,omitempty"` // its ISO 4217 code, upper case: "EUR"
	PriceRate          string            `json:"price_rate,omitempty"`     // dollars per unit of PriceCurrency
	AmountDueUSD       string            `json:"amount_due_usd"`
	FeesPaidByCustomer bool              `json:"fees_paid_by_customer"`
	NetworkFee         string            `json:"network_fee"`
	NetworkFeeUSD      string            `json:"network_fee_usd"`
	Settlement         string            `json:"settlement"` // "custodial" or "direct"
	ReceivedUSD        string            `json:"received_usd"`
	RateLocked         string            `json:"rate_locked"`
	TxID               string            `json:"tx_id,omitempty"`
	Overpaid           bool              `json:"overpaid,omitempty"`
	PaidLate           bool              `json:"paid_late,omitempty"`
	Deposits           []Deposit         `json:"deposits"`
	ExpiresAt          time.Time         `json:"expires_at"`
	CreatedAt          time.Time         `json:"created_at"`
	ConfirmedAt        *time.Time        `json:"confirmed_at,omitempty"`
	NetworkFeeNote     string            `json:"network_fee_note"`
	QRPayload          string            `json:"qr_payload"`
	CheckoutToken      string            `json:"checkout_token,omitempty"`
	CheckoutURL        string            `json:"checkout_url,omitempty"`
	Description        string            `json:"description,omitempty"`
	Metadata           map[string]string `json:"metadata,omitempty"`
	CustomerEmail      string            `json:"customer_email,omitempty"`
	SuccessURL         string            `json:"success_url,omitempty"`
	CancelURL          string            `json:"cancel_url,omitempty"`
	WebhookURL         string            `json:"webhook_url,omitempty"`
}

Statuses (lifecycle):

const (
	StatusPending       = "pending"
	StatusPartiallyPaid = "partially_paid"
	StatusDetecting     = "detecting"
	StatusConfirmed     = "confirmed" // the only one that means you have been paid
	StatusUnderpaid     = "underpaid"
	StatusExpired       = "expired"
	StatusFailed        = "failed"
)

Simulation

A test payment's simulated outcome. nil when it has none.

type Simulation struct {
	Outcome string `json:"outcome"` // an Outcome… constant
	State   string `json:"state"`   // a Simulation… constant
}

const (
	OutcomeConfirmed     = "confirmed"
	OutcomeOverpaid      = "overpaid"
	OutcomePartiallyPaid = "partially_paid"
	OutcomeUnderpaid     = "underpaid"
	OutcomeExpired       = "expired"
	OutcomeFailed        = "failed"
	OutcomeWrongAsset    = "wrong_asset"
	OutcomePaidLate      = "paid_late"

	SimulationRequested = "requested" // asked for on create, waiting for the coin
	SimulationRunning   = "running"
	SimulationDone      = "done"
)

Deposit

One transfer to the payment's address (the deposit object).

type Deposit struct {
	TxID        string     `json:"tx_id"`
	Token       string     `json:"token"` // the asset that arrived
	Chain       string     `json:"chain"`
	Amount      string     `json:"amount"`
	USDValue    string     `json:"usd_value"`
	Counted     bool       `json:"counted"`        // counts toward the payment
	Late        bool       `json:"late,omitempty"` // arrived after the payment had closed
	Status      string     `json:"status"`         // "pending", "confirmed" or "failed"
	SeenAt      time.Time  `json:"seen_at"`
	ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
	From        string     `json:"from,omitempty"`        // the sender, when the network reports it: where a refund would go
	Block       int64      `json:"block,omitempty"`       // the block (XRP: the ledger) that holds it
	MadeAt      *time.Time `json:"made_at,omitempty"`     // that block's time
	FailedAt    *time.Time `json:"failed_at,omitempty"`   // when it failed
	FailReason  string     `json:"fail_reason,omitempty"` // "transaction not found on chain" or "confirmation timeout"
	Simulated   bool       `json:"simulated,omitempty"`   // played by a test payment's simulation: its TxID is on no chain
}

PaymentParams

The parameters of Payments.Create.

type PaymentParams struct {
	OrderID       string            `json:"order_id"`
	AmountUSD     string            `json:"amount_usd,omitempty"`     // the price in dollars, or:
	PriceAmount   string            `json:"price_amount,omitempty"`   // the price in another currency…
	PriceCurrency string            `json:"price_currency,omitempty"` // …and its ISO 4217 code ("EUR")
	Token         string            `json:"token,omitempty"`
	Tokens        []string          `json:"tokens,omitempty"`
	Description   string            `json:"description,omitempty"`
	Metadata      map[string]string `json:"metadata,omitempty"`
	CustomerEmail string            `json:"customer_email,omitempty"`
	SuccessURL    string            `json:"success_url,omitempty"`
	CancelURL     string            `json:"cancel_url,omitempty"`
	WebhookURL    string            `json:"webhook_url,omitempty"`
	Simulate      string            `json:"simulate,omitempty"` // test keys only: an Outcome… constant
}

ListParams

The parameters of Payments.List.

type ListParams struct {
	Status  string
	Chain   string
	Token   string
	StoreID string // store_id
	Limit   int
	Offset  int
}

PaymentList

type PaymentList struct {
	Payments []Payment `json:"payments"`
	Total    int       `json:"total"`  // matches across all pages
	Limit    int       `json:"limit"`  // the page size applied
	Offset   int       `json:"offset"` // the offset applied
}

TokenInfo

A coin the customer can pay with (the TokenInfo object).

type TokenInfo struct {
	Token         string `json:"token"`
	Chain         string `json:"chain"`
	MinUSD        string `json:"min_usd"`
	NetworkFeeUSD string `json:"network_fee_usd"`
	AmountDueUSD  string `json:"amount_due_usd,omitempty"` // with an amount, when you pass the fees on
	FeeNote       string `json:"fee_note"`
}

type TokenListParams struct {
	AmountUSD string // "" lists every coin
}

Webhook

The webhook of the key's mode (the webhook object).

type Webhook struct {
	Livemode  bool      `json:"livemode"`
	URL       string    `json:"url"`    // "" when none is set (null in the API)
	Secret    string    `json:"secret"` // signs the mode's notifications
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

WebhookDelivery

One notification on its way to one URL (the delivery object).

type WebhookDelivery struct {
	ID             string                   `json:"id"`
	MerchantID     string                   `json:"merchant_id"`
	Target         string                   `json:"target"` // TargetAccount or TargetPayment
	URL            string                   `json:"url"`
	EventID        string                   `json:"event_id"`
	EventType      string                   `json:"event_type"`
	Payload        string                   `json:"payload"` // the exact body posted
	Status         string                   `json:"status"`  // a Delivery… constant
	Attempts       int                      `json:"attempts"`
	MaxAttempts    int                      `json:"max_attempts"`
	NextAttemptAt  time.Time                `json:"next_attempt_at"`
	LastStatusCode int                      `json:"last_status_code,omitempty"`
	LastError      string                   `json:"last_error,omitempty"`
	LastAttemptAt  *time.Time               `json:"last_attempt_at,omitempty"`
	DeliveredAt    *time.Time               `json:"delivered_at,omitempty"`
	ResolvedAt     *time.Time               `json:"resolved_at,omitempty"`
	ResolutionNote string                   `json:"resolution_note,omitempty"`
	CreatedAt      time.Time                `json:"created_at"`
	History        []WebhookDeliveryAttempt `json:"history,omitempty"`
}

type WebhookDeliveryAttempt struct {
	At         time.Time `json:"at"`
	URL        string    `json:"url,omitempty"`
	Signed     string    `json:"signed,omitempty"` // the t of the attempt's signature
	StatusCode int       `json:"status_code,omitempty"`
	Error      string    `json:"error,omitempty"`
	Response   string    `json:"response,omitempty"` // the start of your server's answer
	Millis     int64     `json:"millis,omitempty"`
	Note       string    `json:"note,omitempty"` // a redelivery or resolution, and by whom
}

const (
	DeliveryPending   = "pending"
	DeliverySending   = "sending"
	DeliveryDelivered = "delivered"
	DeliveryFailed    = "failed"
	DeliveryResolved  = "resolved"

	TargetAccount = "account" // the mode's webhook URL
	TargetPayment = "payment" // the payment's own webhook_url
)

Event

A verified notification (the body).

type Event struct {
	ID        string          `json:"id"` // the same on every retry: deduplicate on it
	Type      string          `json:"type"`
	Livemode  bool            `json:"livemode"`
	CreatedAt time.Time       `json:"created_at"`
	Data      json.RawMessage `json:"data"`
}

// Payment decodes Data, for payment.* events.
func (e *Event) Payment() (*Payment, error)

Data is the payment for payment.* events (decode it with event.Payment()), the settlement for settlement.*, the payout for payout.*, and {"message": "pong"} for ping; decode the others with json.Unmarshal(event.Data, &yours). The event types:

const (
	EventPaymentPartiallyPaid = "payment.partially_paid"
	EventPaymentDetecting     = "payment.detecting"
	EventPaymentConfirmed     = "payment.confirmed"
	EventPaymentUnderpaid     = "payment.underpaid"
	EventPaymentExpired       = "payment.expired"
	EventPaymentFailed        = "payment.failed"
	EventPaymentWrongAsset    = "payment.wrong_asset"
	EventPaymentLateDeposit   = "payment.late_deposit"
	EventPayoutApproved       = "payout.approved"
	EventPayoutRejected       = "payout.rejected"
	EventPayoutSent           = "payout.sent"
	EventSettlementCompleted  = "settlement.completed"
	EventSettlementFailed     = "settlement.failed"
	EventPing                 = "ping"
)

Examples

Your own payment page

Show the coins on your own site, create the payment in the coin the customer picks, and render the amount, address, QR code and deadline from the payment itself (what to show). net/http and html/template, with github.com/skip2/go-qrcode for the QR code:

package main

import (
	"encoding/base64"
	"errors"
	"html/template"
	"log"
	"net/http"
	"os"
	"strings"

	"github.com/skip2/go-qrcode"
	payitgo "github.com/payitgo/go-sdk"
)

var client *payitgo.Client

var networks = map[string]string{
	"tron": "Tron (TRC-20)", "ethereum": "Ethereum (ERC-20)", "bsc": "BNB Smart Chain (BEP-20)",
	"bitcoin": "Bitcoin", "litecoin": "Litecoin", "dogecoin": "Dogecoin", "xrp": "XRP Ledger",
}

var page = template.Must(template.New("pay").Funcs(template.FuncMap{
	"network": func(chain string) string {
		if n, ok := networks[chain]; ok {
			return n
		}
		return chain
	},
	"coin": func(token string) string { return strings.Split(token, "_")[0] },
}).Parse(`
{{if .Tokens}}<form method="post">{{range .Tokens}}
  <button name="token" value="{{.Token}}">{{.Token}} on {{network .Chain}}: ${{or .AmountDueUSD $.Total}}</button>
{{end}}</form>{{end}}
{{with .Payment}}
  {{if not .Livemode}}<p><b>Test payment:</b> do not send coins to this address.</p>{{end}}
  <p>Send exactly <b>{{.CryptoAmount}} {{coin .Token}}</b> on <b>{{network .Chain}}</b> to</p>
  <p><code>{{.DepositAddress}}</code></p>
  {{if .DestinationTag}}<p>Destination tag (required): <b>{{.DestinationTag}}</b></p>{{end}}
  <img src="{{$.QR}}" width="220" height="220" alt="QR code">
  <p>Pay before <time datetime="{{.ExpiresAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.ExpiresAt.Format "15:04 MST"}}</time></p>
{{end}}`))

type view struct {
	Total   string
	Tokens  []payitgo.TokenInfo
	Payment *payitgo.Payment
	QR      template.URL
}

// GET /pay?order=ORD-1001 lists the coins; POST creates the payment in the chosen one.
func pay(w http.ResponseWriter, r *http.Request) {
	order, err := findOrder(r.Context(), r.URL.Query().Get("order")) // your code: ID and TotalUSD ("49.90")
	if err != nil {
		http.NotFound(w, r)
		return
	}
	if r.Method != http.MethodPost {
		tokens, err := client.Tokens.List(r.Context(), &payitgo.TokenListParams{AmountUSD: order.TotalUSD})
		if err != nil {
			http.Error(w, "please try again", http.StatusBadGateway)
			return
		}
		page.Execute(w, view{Total: order.TotalUSD, Tokens: tokens})
		return
	}
	payment, err := client.Payments.Create(r.Context(), &payitgo.PaymentParams{
		OrderID:   order.ID, // the same order again returns its open payment
		AmountUSD: order.TotalUSD,
		Token:     r.FormValue("token"),
	})
	var perr *payitgo.Error
	if errors.As(err, &perr) {
		log.Printf("payitgo %d: %s", perr.Status, perr.Message) // log it; do not show it to the customer
	}
	if err != nil {
		http.Error(w, "We could not start this payment. Please try again.", http.StatusBadGateway)
		return
	}
	renderPayment(w, payment)
}

func renderPayment(w http.ResponseWriter, p *payitgo.Payment) {
	png, err := qrcode.Encode(p.QRPayload, qrcode.Medium, 440)
	if err != nil {
		http.Error(w, "please try again", http.StatusInternalServerError)
		return
	}
	qr := template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png))
	page.Execute(w, view{Payment: p, QR: qr})
}

func main() {
	var err error
	if client, err = payitgo.NewClient(os.Getenv("PAYITGO_KEY")); err != nil {
		log.Fatal(err)
	}
	http.HandleFunc("/pay", pay)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

A payment keeps its coin while it is open: if the customer goes back and picks another coin, Payments.Create for the same order returns the first payment unchanged. To let the customer choose on your page after the payment exists, create it without Token and set the coin with SelectToken:

payment, err := client.Payments.Create(ctx, &payitgo.PaymentParams{OrderID: order.ID, AmountUSD: order.TotalUSD})
if err != nil {
	return err
}
// render payment.TokenOptions (each with Token, Chain, MinUSD, AmountDueUSD, FeeNote)…
// …and when the customer picks one:
chosen, err := client.Payments.SelectToken(ctx, payment.ID, pickedToken)
if err != nil {
	return err
}
renderPayment(w, chosen)

To follow the payment live on the page, connect the page's script to the socket's default namespace (/) and subscribe with payment.CheckoutToken (with an acknowledgement): every payment:update then carries the whole payment (follow it live). No polling. Fulfil the order from the webhook, never from the page.

A webhook (IPN) handler with net/http

Read the raw body, verify it, handle each event once, and answer 2xx within 10 seconds (answering and retries):

package main

import (
	"io"
	"log"
	"net/http"
	"os"

	payitgo "github.com/payitgo/go-sdk"
)

var client *payitgo.Client

func ipn(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // the raw bytes
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	event, err := client.Webhooks.ConstructEvent(body, r.Header.Get("X-Webhook-Signature"))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest) // not from payitgo, changed, or too old
		return
	}

	ctx := r.Context()
	done, err := isProcessed(ctx, event.ID)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError) // payitgo retries
		return
	}
	if done {
		w.WriteHeader(http.StatusOK) // a retry or redelivery
		return
	}

	live, err := client.IsLive(ctx) // asks payitgo once, then remembers
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError) // payitgo retries
		return
	}
	if event.Livemode == live { // ignore the other mode's events
		switch event.Type {
		case payitgo.EventPaymentConfirmed: // final: fulfil
			p, err := event.Payment()
			if err == nil {
				err = markOrderPaid(ctx, p.OrderID, p.ID) // idempotent
			}
			if err != nil {
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
		case payitgo.EventPaymentExpired: // nothing arrived in time
			p, err := event.Payment()
			if err == nil {
				err = releaseOrder(ctx, p.OrderID)
			}
			if err != nil {
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
		}
	}

	if err := markProcessed(ctx, event.ID); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	var err error
	client, err = payitgo.NewClient(os.Getenv("PAYITGO_KEY"),
		payitgo.WithWebhookSecret(os.Getenv("PAYITGO_WEBHOOK_SECRET")), // the signing secret of the key's mode
	)
	if err != nil {
		log.Fatal(err)
	}
	http.HandleFunc("/payitgo/ipn", ipn)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

isProcessed, markProcessed, markOrderPaid and releaseOrder are your own code: store processed event ids in a table with a unique key, and make fulfilment safe to run twice. A 500 makes payitgo retry the notification. More in handle events safely.

Test mode

Build everything with your test key: the same calls, simulated payments instead of coins, and the test webhook with its own secret (Test mode).

client, err := payitgo.NewClient(os.Getenv("PAYITGO_TEST_KEY"), payitgo.WithBaseURL("https://api.payitgo.co")) // your test key
if err != nil {
	return err
}
live, err := client.IsLive(ctx)
if err != nil {
	return err
}
fmt.Println(live) // false

// Point the TEST webhook at your development server and read its secret.
hook, err := client.Webhooks.Set(ctx, "https://dev.acme.example/payitgo/ipn")
if err != nil {
	return err
}
fmt.Println(hook.Secret) // test mode's signing secret: your dev PAYITGO_WEBHOOK_SECRET

// A signed ping arrives within seconds.
if _, err := client.Webhooks.Test(ctx); err != nil {
	return err
}

// A test payment that pays itself: payment.detecting, then payment.confirmed,
// arrive at the test webhook within half a minute.
payment, err := client.Payments.Create(ctx, &payitgo.PaymentParams{
	OrderID: "TEST-1", AmountUSD: "10", Token: "USDT_TRC20", Simulate: payitgo.OutcomeConfirmed,
})
if err != nil {
	return err
}
fmt.Println(payment.Livemode, payment.Simulation.State) // false running

// Another outcome on a payment you already have:
if _, err := client.Payments.Simulate(ctx, "TEST-2", payitgo.OutcomeUnderpaid); err != nil {
	return err
}

Never send real coins to a test address: nothing watches it (why).

Check your verification against the example in the recipe (tolerance 0, because the example is old):

body := []byte(`{"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}`)
event, err := payitgo.ConstructEventWithTolerance(body,
	"t=1791623565,v1=1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b",
	"a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48",
	0,
)
if err != nil {
	return err
}
fmt.Println(event.Type) // ping

To test your handler without the network, sign a body yourself and call it with httptest:

func sign(body []byte, secret string, t time.Time) string {
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.", t.Unix())
	mac.Write(body)
	return fmt.Sprintf("t=%d,v1=%s", t.Unix(), hex.EncodeToString(mac.Sum(nil)))
}

func TestIPN(t *testing.T) {
	body := []byte(`{"id":"6710c3a1f2b4e5d6a7b8c9d0","type":"ping","livemode":false,"created_at":"2026-10-10T09:00:00Z","data":{"message":"pong"}}`)
	req := httptest.NewRequest(http.MethodPost, "/payitgo/ipn", bytes.NewReader(body))
	req.Header.Set("X-Webhook-Signature", sign(body, os.Getenv("PAYITGO_WEBHOOK_SECRET"), time.Now()))
	rec := httptest.NewRecorder()
	ipn(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("status %d", rec.Code)
	}
}

Going live: put the live key and the live webhook secret in your production environment, set the live webhook URL with the live key, and keep the event.Livemode check in your handler (going live).