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.
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
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).