Webhooks (IPN)
payitgo's webhooks are its IPN (instant payment notifications): whenever
one of your payments changes, payitgo sends an HTTPS POST to your server.
The webhook secret is the signing key (what some gateways call the IPN
secret): every
notification is signed with it, so your server can prove the request came from
payitgo and was not changed on the way.
Fulfil orders from these notifications. They are signed, retried for over a day until your server acknowledges them, and arrive even if the customer closed the browser.
How it works
Your account has two webhooks: one for test mode and one for live mode. Each has:
| A URL | Where every notification of that mode is POSTed. Optional: null until you set it |
| A signing secret | 64 hex characters. Made automatically, it signs every notification of that mode. Read it any time; the account owner can roll it |
A payment can also name its own webhook_url when it is created: that
payment's notifications go there instead of the account's URL, signed
with the same secret.
| Notification | Goes to |
|---|---|
payment.* and settlement.* of a payment created with webhook_url |
That payment's webhook_url |
payment.* and settlement.* of any other payment |
The webhook URL of the payment's mode |
payout.* |
The live webhook URL (payouts are live only) |
ping (a test) |
The webhook URL of the key's mode |
Your API key picks the mode: with your test key you read and change the test
webhook, with your live key the live one. Test notifications say
"livemode": false and are signed with the test secret.
If a notification has nowhere to go (no webhook_url on the payment and no
URL for the mode), nothing is sent, and nothing is sent later either: setting
a URL afterwards does not replay old events. Set your URL before you take
payments. To catch up, read the payments with
GET /v1/payments.
Set it up
1. Read your webhook and its secret with your API key:
curl -s https://api.payitgo.co/v1/webhook -H "Authorization: Bearer $PAYITGO_KEY"
{
"webhook": {
"livemode": false,
"url": null,
"secret": "6e86b3f1…a8418ae0",
"created_at": "2026-10-10T07:48:44.128Z",
"updated_at": "2026-10-10T07:48:44.128Z"
}
}
Store secret on your server, next to your API key (for example as
PAYITGO_WEBHOOK_SECRET). It is the same for every notification of this mode.
2. Set the URL your server receives notifications on:
curl -s -X PUT https://api.payitgo.co/v1/webhook \
-H "Authorization: Bearer $PAYITGO_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://acme.example/payitgo/ipn"}'
3. Send yourself a test and check that your handler verifies it and
answers 200:
curl -s -X POST https://api.payitgo.co/v1/webhook/test -H "Authorization: Bearer $PAYITGO_KEY"
Do the same with your live key before going live: the live webhook has its own URL and its own secret.
The webhook API
All four routes take your API key and act on the webhook of the key's mode.
The dashboard has the same routes under /merchant/webhook, plus rolling the
secret (Account and team).
| Method | Path | What it does |
|---|---|---|
GET |
/v1/webhook |
Read the URL and the signing secret |
PUT |
/v1/webhook |
Set the URL. Safe to retry |
DELETE |
/v1/webhook |
Remove the URL; the secret is kept |
POST |
/v1/webhook/test |
Send a signed ping to the URL |
The webhook object
{
"livemode": false,
"url": "https://acme.example/payitgo/ipn",
"secret": "6e86b3f1…a8418ae0",
"created_at": "2026-10-10T07:48:44.128Z",
"updated_at": "2026-10-10T07:48:44.251Z"
}
| Field | Type | Description |
|---|---|---|
livemode |
boolean | false for the test webhook, true for the live one |
url |
string or null |
Where the mode's notifications are posted. null when none is set |
secret |
string | The signing secret of the mode: 64 hex characters. Verify every notification with it |
created_at |
string | When the webhook was first made (the first time it was read) |
updated_at |
string | When the URL or the secret last changed |
Read the webhook
GET /v1/webhook · API key (storefront keys too)
No parameters. 200 OK with {"webhook": {…}}, as in step 1.
The webhook and its secret are made the first time they are read, so this
always succeeds.
Set the URL
PUT /v1/webhook · API key (not a storefront key)
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | yes | An absolute https:// URL on a public host that accepts POST. Any path and query, such as https://acme.example/payitgo/ipn?shop=eu |
curl -s -X PUT https://api.payitgo.co/v1/webhook \
-H "Authorization: Bearer $PAYITGO_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://acme.example/payitgo/ipn"}'
200 OK:
{
"webhook": {
"livemode": false,
"url": "https://acme.example/payitgo/ipn",
"secret": "6e86b3f1…a8418ae0",
"created_at": "2026-10-10T07:48:44.128Z",
"updated_at": "2026-10-10T07:48:44.251Z"
}
}
Setting the URL it already has changes nothing (not even updated_at), so the
request is safe to retry. A new URL takes effect at once, also for
notifications already waiting to be retried. The secret does not change.
| Status | Message | Cause |
|---|---|---|
400 |
url is required |
No url, or an empty one |
400 |
url must be an absolute http(s) URL |
Not an absolute URL (shop.example/ipn), or one with a user name or password in it |
400 |
url must use https |
An http:// URL |
400 |
url host does not resolve |
The host name has no DNS record |
400 |
url must point at a public host |
localhost, or a private, loopback or link-local address |
400 |
invalid request body |
The body is not JSON |
401 |
missing api key · invalid api key |
Authentication |
403 |
a storefront's key cannot change the account's webhook: set webhook_url on its payments instead |
A storefront key |
500 |
could not set the webhook |
Retry |
Remove the URL
DELETE /v1/webhook · API key (not a storefront key)
Stops posting the mode's notifications to its URL. Payments created with their
own webhook_url still get theirs. The secret is kept.
curl -s -X DELETE https://api.payitgo.co/v1/webhook -H "Authorization: Bearer $PAYITGO_KEY"
200 OK:
{
"webhook": {
"livemode": false,
"url": null,
"secret": "6e86b3f1…a8418ae0",
"created_at": "2026-10-10T07:48:44.128Z",
"updated_at": "2026-10-10T07:49:29.926Z"
}
}
Removing a URL that is already removed changes nothing. Notifications already
waiting to be retried to the account's URL fail while it has none (no webhook url is set for this mode), and keep being retried on the
schedule. Errors: 403 for a storefront key, 500 could not remove the webhook.
Send a test
POST /v1/webhook/test · API key (storefront keys too)
Queues a signed ping notification to the mode's URL, so you can watch one
arrive and check your verification. It is sent within about 5 seconds.
curl -s -X POST https://api.payitgo.co/v1/webhook/test -H "Authorization: Bearer $PAYITGO_KEY"
202 Accepted, with the delivery on its way:
{
"delivery": {
"id": "3b7f1d9a5c2e8f4a6d0b2c1e",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"livemode": false,
"target": "account",
"url": "https://acme.example/payitgo/ipn",
"event_id": "5c0e1f9a2b7d4e3c8a6f1b2d",
"event_type": "ping",
"payload": "{\"created_at\":\"2026-10-10T09:12:44.512837Z\",\"data\":{\"message\":\"pong\"},\"id\":\"5c0e1f9a2b7d4e3c8a6f1b2d\",\"livemode\":false,\"type\":\"ping\"}",
"status": "pending",
"attempts": 0,
"max_attempts": 7,
"next_attempt_at": "2026-10-10T09:12:44.513627769Z",
"created_at": "2026-10-10T09:12:44.513627769Z"
}
}
| Status | Message | Cause |
|---|---|---|
400 |
no webhook url is set for this mode: set one first |
The mode has no URL |
500 |
could not queue the test |
Retry |
Each call sends one more ping. A ping is retried like any notification if
your server does not answer 2xx.
The delivery object
A delivery is one notification on its way to one URL, with every attempt recorded. The test returns one; the dashboard lists them (delivery history).
| Field | Type | Description |
|---|---|---|
id |
string | The delivery's id. Sent as the X-Webhook-Id header, the same on every attempt |
merchant_id |
string | Your account |
livemode |
boolean | false for a delivery of a test event |
target |
string | account (the mode's webhook URL, as it is at each attempt) or payment (the payment's own webhook_url) |
url |
string | Where it was last posted, updated on every attempt. Before the first attempt, where it is going. Each attempt's URL is also in history[].url |
event_id |
string | The event: the id in the body |
event_type |
string | The event type, such as payment.confirmed |
payload |
string | The exact body posted, as a string |
status |
string | pending (waiting for its next attempt), sending (being sent now), delivered (your server answered 2xx), failed (all 7 attempts failed) or resolved (a failed delivery payitgo's team closed) |
attempts |
integer | Attempts made so far |
max_attempts |
integer | 7 |
next_attempt_at |
string | When the next attempt is due |
last_status_code |
integer, optional | Your server's last HTTP status |
last_error |
string, optional | Why the last attempt failed, such as endpoint answered 500 |
last_attempt_at |
string, optional | When the last attempt was made |
delivered_at |
string, optional | When your server answered 2xx |
resolved_at, resolution_note |
string, optional | When and why a failed delivery was closed |
created_at |
string | When the delivery was made |
history |
array, optional | Every attempt, redelivery and resolution, oldest first (the latest 50) |
Each history entry:
| Field | Description |
|---|---|
at |
When |
url |
Where the attempt was posted |
signed |
The t of its signature |
status_code |
Your server's HTTP status |
error |
Why it failed, if it did |
response |
The start of your server's answer (up to 500 characters) |
millis |
How long your server took |
note |
For a redelivery or resolution: who did it, such as redelivered by merchant you@acme.example |
Per-payment webhook URL
Pass webhook_url when you create a payment
and that payment's notifications go there instead of the account's URL:
curl -s https://api.payitgo.co/v1/payments \
-H "Authorization: Bearer $PAYITGO_KEY" \
-H 'Content-Type: application/json' \
-d '{"order_id": "ORD-1003", "amount_usd": 30, "token": "USDT_TRC20", "webhook_url": "https://acme.example/orders/ORD-1003/ipn"}'
- It receives the payment's
payment.*events and thesettlement.*events of its deposits. Nothing about that payment goes to the account's URL. - It is signed with the same secret as the account's URL in that mode: verify it the same way.
- It must be
https://on a public host, checked when the payment is created (400 webhook_url: url must use https, and the other URL errors above) and again before each attempt. - The payment object shows it as
webhook_url. - It is fixed at creation: a retried create for the same open order returns the payment unchanged.
Use it when different payments belong to different backends: one per store (a
storefront key cannot change the
account's URL, so a store sets webhook_url on its payments), one per tenant
of your platform, or a callback per order. The libraries
can add it to every payment a client creates.
A notification sent to a payment's own URL looks exactly like any other. This one was captured from a test payment:
{
"created_at": "2026-10-10T08:03:13.934193672Z",
"data": {
"id": "2d9b6e14a7c3f0851e4b9a6c",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1003",
"status": "expired",
"deposit_address": "TGs8Nx3kV6qR1wD9mP4hB7cL2fJ5tYzEaH",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": false,
"crypto_amount": "30",
"received_amount": "0",
"usd_amount": "30",
"amount_due_usd": "30",
"fees_paid_by_customer": false,
"network_fee": "3.212009",
"network_fee_usd": "3.22",
"settlement": "direct",
"received_usd": "0",
"rate_locked": "1",
"deposits": [],
"expires_at": "2026-10-10T08:02:43.856Z",
"created_at": "2026-10-10T07:47:43.855Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TGs8Nx3kV6qR1wD9mP4hB7cL2fJ5tYzEaH",
"webhook_url": "https://acme.example/orders/ORD-1003/ipn"
},
"id": "e8a1c3f5b7d9e2a4c6f8b0d1",
"livemode": false,
"type": "payment.expired"
}
The signing secret
- Where to find it:
GET /v1/webhookwith your API key (any key of the mode, storefront keys included), orGET /merchant/webhook?mode=testin the dashboard (owner or developer). - One per mode. The test secret signs test notifications, the live secret live ones. Use the secret of the same mode as the key.
- Rolling it: the account owner calls
POST /merchant/webhook/roll-secret?mode=live(how). The new secret signs every notification from then on, retries of older ones included, and the old one stops working at once. To roll without missing a notification, deploy code that accepts either secret, roll, store the new one, then remove the old. - Keep it secret. Anyone who has it can forge notifications. Keep it on your server, never in a browser or app, and roll it if it may have leaked.
The notification
payitgo sends one POST per event:
POST /payitgo/ipn HTTP/1.1
Host: acme.example
Content-Type: application/json
User-Agent: payitgo-webhooks/1.0
X-Webhook-Id: 8e3c1a7f5d9b2e4c6a0f1d3b
X-Webhook-Event: payment.expired
X-Webhook-Attempt: 1
X-Webhook-Signature: t=1791619398,v1=eecab6f6b8af5b15421c86d330ed4be369200c10ebb8524e33b30d8bf9e65c08
Content-Length: 959
{"created_at":"2026-10-10T08:03:13.937152328Z","data":{"id":"6c4a2e8f0b1d3f5a7c9e2b4d","merchant_id":"3f8a1c27b94e05d6a2c71e90","order_id":"ORD-1004","status":"expired","deposit_address":"TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX","token":"USDT_TRC20","chain":"tron","livemode":false,"crypto_amount":"49.9","received_amount":"0","usd_amount":"49.9","amount_due_usd":"49.9","fees_paid_by_customer":false,"network_fee":"3.212009","network_fee_usd":"3.22","settlement":"direct","received_usd":"0","rate_locked":"1","deposits":[],"expires_at":"2026-10-10T08:02:43.72Z","created_at":"2026-10-10T07:47:43.719Z","network_fee_note":"Final in about a minute.","qr_payload":"TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX","description":"2 x Acme T-shirt","metadata":{"customer_id":"cus_42"},"customer_email":"jane@example.com","success_url":"https://acme.example/thanks","cancel_url":"https://acme.example/cart"},"id":"9d4b2f7e1c6a3e08b5d2f1c4","livemode":false,"type":"payment.expired"}
Headers
| Header | Example | Meaning |
|---|---|---|
Content-Type |
application/json |
The body is JSON |
User-Agent |
payitgo-webhooks/1.0 |
Identifies payitgo's sender |
X-Webhook-Id |
8e3c1a7f5d9b2e4c6a0f1d3b |
The delivery. The same on every retry and redelivery of this notification |
X-Webhook-Event |
payment.expired |
The event type, the same as type in the body: route on it without parsing |
X-Webhook-Attempt |
1 |
Which attempt this is, 1 to 7. Starts again at 1 after a redelivery |
X-Webhook-Signature |
t=1791619398,v1=eecab6…5c08 |
t is the Unix time the attempt was signed; v1 is the signature. See verifying |
Body
The body is one line of JSON (shown indented here):
{
"created_at": "2026-10-10T08:03:13.937152328Z",
"data": {
"id": "6c4a2e8f0b1d3f5a7c9e2b4d",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1004",
"status": "expired",
"deposit_address": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": false,
"crypto_amount": "49.9",
"received_amount": "0",
"usd_amount": "49.9",
"amount_due_usd": "49.9",
"fees_paid_by_customer": false,
"network_fee": "3.212009",
"network_fee_usd": "3.22",
"settlement": "direct",
"received_usd": "0",
"rate_locked": "1",
"deposits": [],
"expires_at": "2026-10-10T08:02:43.72Z",
"created_at": "2026-10-10T07:47:43.719Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
"description": "2 x Acme T-shirt",
"metadata": { "customer_id": "cus_42" },
"customer_email": "jane@example.com",
"success_url": "https://acme.example/thanks",
"cancel_url": "https://acme.example/cart"
},
"id": "9d4b2f7e1c6a3e08b5d2f1c4",
"livemode": false,
"type": "payment.expired"
}
| Field | Type | Description |
|---|---|---|
id |
string | The event's id: 24 hex characters, treated as an opaque string. The same on every retry and redelivery: deduplicate on it |
type |
string | What happened: one of the events |
livemode |
boolean | false for a test event. Never fulfil a real order from a test event |
created_at |
string | When the event happened (RFC 3339, UTC) |
data |
object | What it happened to: the payment for payment.*, the settlement for settlement.*, the payout for payout.*, {"message": "pong"} for ping. Every amount in a coin has its dollar value next to it |
The payment in data is the payment object as GET /v1/payments/{id} returns
it at that moment, except that it never carries checkout_token or
token_options. A simulated test payment's carries its simulation, and its
deposits "simulated": true (Test mode).
The keys come in alphabetical order at the top level, and the body has no spaces. Do not rely on either: always verify the raw bytes you received, and parse them as ordinary JSON.
Events
| Type | Sent when | data |
|---|---|---|
payment.partially_paid |
Part of the amount arrived; the customer can still send the rest before expires_at |
Payment |
payment.detecting |
The full amount arrived; waiting for confirmations. Not final | Payment |
payment.confirmed |
Final: the money is yours. Fulfil the order | Payment |
payment.underpaid |
The payment closed with only part of the amount (at the deadline, or a late deposit that was still not enough) | Payment |
payment.expired |
The deadline passed and nothing arrived (or the customer never chose a coin) | Payment |
payment.failed |
Funds were seen but never became final | Payment |
payment.wrong_asset |
A deposit in another coin than the payment asked for arrived (wrong asset) | Payment |
payment.late_deposit |
A deposit arrived after the payment had closed; it is settled like any other (late payments) | Payment |
settlement.completed |
Direct settlement: your share of a deposit reached your wallet; data.tx_id is the transfer (for a deposit converted across networks, the exchange's payout to your wallet; a test settlement completes on paper, with no tx_id) |
Settlement |
settlement.failed |
Direct settlement: your share could not be sent after repeated tries; payitgo's team retries it or returns it to your balance | Settlement |
payout.approved |
payitgo approved a payout you requested | Payout |
payout.rejected |
payitgo rejected a payout; the amount is back in your balance (data.review_note says why) |
Payout |
payout.sent |
A payout is on chain; data.tx_id is the transaction |
Payout |
ping |
You sent a test | {"message": "pong"} |
No event is sent when a payment is created. One deposit can raise two events
at once: a late deposit that completes a payment raises payment.detecting
and payment.late_deposit, both with the same payment. Every event type has a
full example at the end of this page.
Amounts in dollars
The amounts in a coin in data come with their dollar values, so you can
book a BTC or ETH payment in dollars without looking up a price. Dollar values are
at the rate fixed at the time (the payment's locked rate, the settlement, the
payout request), not today's price. Like every amount, they are decimal
strings without trailing zeros ("0.5" is $0.50).
The payment:
| In the coin | In dollars |
|---|---|
crypto_amount |
amount_due_usd |
received_amount |
received_usd |
network_fee |
network_fee_usd |
deposits[].amount |
deposits[].usd_value (at rate_locked, or at the market price for another coin) |
rate_locked: the dollar price of one token, locked when the coin was chosen |
The settlement:
| In the coin | In dollars |
|---|---|
amount |
usd_value |
service_fee |
service_fee_usd |
network_fee |
network_fee_usd |
conversion_fee |
conversion_fee_usd |
net |
net_usd: usd_value less the three fees in dollars, so the dollar figures add up |
usd_rate: the dollar price of one token used (usd_value ÷ amount, to 8 decimals) |
The payout:
| In the coin | In dollars |
|---|---|
amount |
amount_usd |
send_amount |
send_amount_usd |
network_fee |
network_fee_usd |
usd_rate: the coin's dollar price when the payout was requested |
A BTC deposit of 0.00085 to a payment locked at $62,400 settles with (other fields left out):
{
"token": "BTC",
"amount": "0.00085",
"service_fee": "0.0000085",
"network_fee": "0.0000185",
"conversion_fee": "0",
"net": "0.000823",
"usd_value": "53.04",
"usd_rate": "62400",
"service_fee_usd": "0.53",
"network_fee_usd": "1.15",
"conversion_fee_usd": "0",
"net_usd": "51.36"
}
For USDT and USDC, usd_rate is "1" and each dollar figure is the coin
amount rounded to cents.
Verifying the signature
Verify every notification before you trust it. The libraries do it in one call; this section shows what that call does, so you can do it in any language.
With a library
| Language | Call |
|---|---|
| Node.js | payitgo.webhooks.constructEvent(rawBody, req.get("X-Webhook-Signature")) |
| Python | client.webhooks.construct_event(raw_body, request.headers["X-Webhook-Signature"]) |
| PHP | $payitgo->webhooks->constructEvent($rawBody, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE']) |
| Go | client.Webhooks.ConstructEvent(body, r.Header.Get("X-Webhook-Signature")) |
| Dart | payitgo.webhooks.constructEvent(rawBody, request.headers['x-webhook-signature']) |
They use the client's webhookSecret option, or a secret you pass as the third
argument, check the signature and the timestamp, and return the parsed event.
A forged, changed or stale notification throws a signature error: answer
400. Full examples: Libraries.
The recipe
X-Webhook-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( key = secret, message = t + "." + raw_body ) )
- Read the raw request body as bytes, before any JSON parsing. A body parsed and serialized again is not the same bytes, and will not verify.
- Split the header on
,and each part on the first=: taketandv1. If either is missing, reject. - Compute HMAC-SHA256 with your mode's secret as the key (the whole string
as you read it, as UTF-8 bytes) over
t, then., then the raw body. Encode it as lowercase hex. - Compare it with
v1in constant time. If they differ, reject. - Reject if
tis more than 5 minutes away from your clock, so a captured notification cannot be replayed later. Each attempt is signed afresh, so a retry always carries a currentt. - Only now parse the body as JSON.
Answer 400 to a notification that fails; payitgo retries it, and it shows in
your delivery history with endpoint answered 400.
Check your code against this example. With this (made-up) secret,
timestamp and body, your code must produce this v1:
| secret | a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48 |
t |
1791623565 |
| raw body (133 bytes) | {"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"} |
v1 |
1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b |
From a shell:
SECRET='a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48'
T='1791623565'
BODY='{"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex
# SHA2-256(stdin)= 1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b
(Your code will reject this example as too old: skip the timestamp check when you test with it.)
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.PAYITGO_WEBHOOK_SECRET;
const TOLERANCE = 300; // seconds
function verify(rawBody, header) {
const parts = Object.fromEntries(
(header || "").split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
if (!parts.t || !parts.v1) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
return Math.abs(Date.now() / 1000 - Number(parts.t)) <= TOLERANCE;
}
const app = express();
// express.raw keeps the body as bytes. Do not let express.json() run first on this route.
app.post("/payitgo/ipn", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("X-Webhook-Signature"))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // answer first, work after
await handleEvent(event); // see "Handle events safely"
});
Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PAYITGO_WEBHOOK_SECRET"].encode()
TOLERANCE = 300 # seconds
def verify(raw_body: bytes, header: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t or not v1:
return False
expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
return False
return t.isdigit() and abs(time.time() - int(t)) <= TOLERANCE
@app.post("/payitgo/ipn")
def ipn():
raw = request.get_data() # the raw bytes; in Django use request.body
if not verify(raw, request.headers.get("X-Webhook-Signature", "")):
return "", 400
event = json.loads(raw)
handle_event(event) # see "Handle events safely"
return "", 200
PHP
<?php
$secret = getenv('PAYITGO_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // in Laravel: $request->getContent()
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if ($t === '' || $v1 === '' || !hash_equals($expected, $v1) || abs(time() - (int) $t) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
handleEvent($event); // see "Handle events safely"
http_response_code(200);
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func verify(secret, header string, body []byte) bool {
var t, v1 string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
t = v
case "v1":
v1 = v
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || v1 == "" {
return false
}
if d := time.Now().Unix() - ts; d > 300 || d < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(body)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}
func ipn(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !verify(os.Getenv("PAYITGO_WEBHOOK_SECRET"), r.Header.Get("X-Webhook-Signature"), body) {
w.WriteHeader(http.StatusBadRequest)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
Livemode bool `json:"livemode"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
handleEvent(event.ID, event.Type, event.Livemode, event.Data) // see "Handle events safely"
w.WriteHeader(http.StatusOK)
}
Dart (shelf)
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:shelf/shelf.dart';
bool verify(String secret, String? header, List<int> body) {
final parts = <String, String>{};
for (final p in (header ?? '').split(',')) {
final i = p.indexOf('=');
if (i > 0) parts[p.substring(0, i).trim()] = p.substring(i + 1).trim();
}
final t = parts['t'], v1 = parts['v1'];
final ts = t == null ? null : int.tryParse(t);
if (ts == null || v1 == null) return false;
final expected =
Hmac(sha256, utf8.encode(secret)).convert([...utf8.encode('$t.'), ...body]).toString();
if (expected.length != v1.length) return false;
var diff = 0;
for (var i = 0; i < expected.length; i++) {
diff |= expected.codeUnitAt(i) ^ v1.codeUnitAt(i); // constant time
}
final age = DateTime.now().millisecondsSinceEpoch ~/ 1000 - ts;
return diff == 0 && age.abs() <= 300;
}
Future<Response> ipn(Request request) async {
final body = await request.read().expand((chunk) => chunk).toList();
final secret = Platform.environment['PAYITGO_WEBHOOK_SECRET']!;
if (!verify(secret, request.headers['x-webhook-signature'], body)) {
return Response(400);
}
final event = jsonDecode(utf8.decode(body)) as Map<String, dynamic>;
await handleEvent(event); // see "Handle events safely"
return Response.ok('');
}
Keep the raw body
Most verification failures come from a framework parsing the body first:
| Framework | Raw body |
|---|---|
| Express | express.raw({ type: "application/json" }) on the route, before any express.json() |
| Next.js (route handler) | await request.text() |
| Fastify | addContentTypeParser('application/json', { parseAs: 'buffer' }, …) for the route |
| Flask | request.get_data() |
| Django | request.body |
| FastAPI / Starlette | await request.body() |
| Laravel | $request->getContent() |
| Rails | request.raw_post |
| Go | io.ReadAll(r.Body) |
Answering and retries
Answer quickly
Answer any 2xx status within 10 seconds. The body of your answer is ignored (its first 500 characters are kept in the delivery history, to help you debug). Do slow work, such as emails or calls to other services, after answering or in a queue.
Anything else counts as a failure: another status, no answer within 10
seconds, a connection or TLS error. Redirects are not followed: a 301 or
302 (for example from http to https, or a missing trailing slash) is a
failure, so set the exact final URL.
Retries
A failed attempt is retried on this schedule:
| Attempt | When |
|---|---|
| 1 | Within about 5 seconds of the event |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes after attempt 2 |
| 4 | 30 minutes after attempt 3 |
| 5 | 2 hours after attempt 4 |
| 6 | 6 hours after attempt 5 |
| 7 | 24 hours after attempt 6 |
After the seventh failure, about 33 hours after the event, the delivery is
marked failed and payitgo stops. You can redeliver
it at any time. The dashboard's failed_webhooks counts these
(Dashboard).
Every attempt is signed afresh (a new t), carries the same X-Webhook-Id
and body, and increases X-Webhook-Attempt. A notification to the account's
URL goes to the URL as it is at that attempt: if you change your webhook
URL, pending retries follow it.
Handle events safely
- Deduplicate on the event
id. The same notification can arrive more than once: a retry after your answer was lost or slow, or a redelivery. Record eachidyou processed, and do nothing the second time. - Make fulfilment idempotent too. Check the order's state before acting,
so a second
payment.confirmeddoes not ship twice. - Do not rely on order. Notifications can arrive out of order after
retries (a
payment.detectingafter itspayment.confirmed). Usedata.statusas the truth, or fetch the payment withGET /v1/payments/{id}. - Check
livemode. In production, act only on"livemode": true. - Match the order. Find the order by
data.order_id, and comparedata.usd_amountwith your own records. - Fulfil only on
payment.confirmed.payment.detectingmeans the money is on its way, not final.
A handler, in outline:
async function handleEvent(event) {
if (await db.processedEvents.has(event.id)) return; // duplicate
if (event.type === "payment.confirmed" && event.livemode) {
const order = await db.orders.find(event.data.order_id);
if (order && order.status !== "paid" && order.totalUsd === event.data.usd_amount) {
await db.orders.markPaid(order.id, { paymentId: event.data.id });
}
}
await db.processedEvents.add(event.id);
}
Testing your endpoint
-
A ping:
POST /v1/webhook/test(above), orPOST /merchant/webhook/testfrom the dashboard. This is what arrived for a real one:POST /payitgo/ipn HTTP/1.1 Content-Type: application/json User-Agent: payitgo-webhooks/1.0 X-Webhook-Id: 3b7f1d9a5c2e8f4a6d0b2c1e X-Webhook-Event: ping X-Webhook-Attempt: 1 X-Webhook-Signature: t=1791623565,v1=1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b Content-Length: 133 {"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}(signed with the example secret above, so you can replay it against your handler with the timestamp check off).
-
Real payment events: make a test payment and simulate an outcome: its webhooks arrive a few seconds apart, sent by the same processing as live coins (outcomes and their events). Create one and simulate nothing to get
payment.expired15 minutes later. -
On your laptop: payitgo only posts to public
https://URLs. Expose your local server with a tunnel (ngrok, Cloudflare Tunnel or similar) and set the tunnel's URL as your test webhook URL. -
Your error handling: answer
500once and watch the retry arrive a minute later withX-Webhook-Attempt: 2; change one byte of a captured body and check that your handler answers400.
Delivery history and redelivery
The dashboard API keeps every notification, with every attempt and your server's answer (routes):
curl -s 'https://api.payitgo.co/merchant/webhooks/deliveries?mode=test&status=failed' -H "Authorization: Bearer $ACCESS_TOKEN"
curl -s https://api.payitgo.co/merchant/webhooks/deliveries/1a7c3e9f5b2d8046e3c1a9f7 -H "Authorization: Bearer $ACCESS_TOKEN"
curl -s -X POST https://api.payitgo.co/merchant/webhooks/deliveries/1a7c3e9f5b2d8046e3c1a9f7/redeliver -H "Authorization: Bearer $ACCESS_TOKEN"
A redelivery starts the delivery again: attempts back to 0, up to seven more
tries, signed with the current secret and sent to the current URL. Here is a
delivery whose first attempt got a 500; the webhook URL was then changed and
the delivery redelivered, and it arrived:
{
"delivery": {
"id": "1a7c3e9f5b2d8046e3c1a9f7",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"livemode": false,
"target": "account",
"url": "https://acme.example/payitgo/ipn",
"event_id": "7f1d3b5a9c2e4f6a8b0d1c3e",
"event_type": "ping",
"payload": "{\"created_at\":\"2026-10-10T07:49:05.988803785Z\",\"data\":{\"message\":\"pong\"},\"id\":\"7f1d3b5a9c2e4f6a8b0d1c3e\",\"livemode\":false,\"type\":\"ping\"}",
"status": "delivered",
"attempts": 1,
"max_attempts": 7,
"next_attempt_at": "2026-10-10T07:49:22.827Z",
"last_status_code": 200,
"last_attempt_at": "2026-10-10T07:49:23.934Z",
"delivered_at": "2026-10-10T07:49:23.934Z",
"created_at": "2026-10-10T07:49:05.989Z",
"history": [
{ "at": "2026-10-10T07:49:08.934Z", "url": "https://legacy.acme.example/payitgo/ipn", "signed": "1791618548", "status_code": 500, "error": "endpoint answered 500", "response": "ok", "millis": 2 },
{ "at": "2026-10-10T07:49:22.827Z", "note": "redelivered by merchant you@acme.example" },
{ "at": "2026-10-10T07:49:23.934Z", "url": "https://acme.example/payitgo/ipn", "signed": "1791618563", "status_code": 200, "response": "ok", "millis": 1 }
]
}
}
The top-level url is where the last attempt was posted; history[].url
keeps where each attempt went.
Troubleshooting
| Symptom | Check |
|---|---|
| Nothing arrives | Is a URL set for that mode? GET /v1/webhook with the same key you create payments with (test and live are separate). Was the payment created with its own webhook_url? Look at the delivery history: no delivery means there was nowhere to send it |
Deliveries fail with endpoint answered 3xx |
A redirect. Set the final URL (exact scheme, host and trailing slash) |
| Deliveries fail with a timeout | Your handler takes over 10 seconds. Answer first, work after |
| Deliveries fail with a TLS or connection error | Your certificate must be valid for the host, and your firewall must accept connections from the internet |
| Signature never matches | Are you hashing the raw bytes, before any JSON parsing? Is it the secret of the right mode? Was the secret rolled? Did you include the . between t and the body? Are you comparing hex with hex? |
| "Timestamp too old" | Your server's clock is off: sync it with NTP |
| The same event twice | Expected now and then: deduplicate on id |
PUT /v1/webhook answers 403 |
You used a storefront key: use an account-wide key, or set webhook_url on the store's payments |
PUT /v1/webhook answers url must point at a public host |
payitgo does not post to localhost or private networks: use a public URL or a tunnel |
Coming from another gateway
- IPN: payitgo's webhooks are the same idea, a signed
POSTon every payment change. - A callback URL per payment:
webhook_urlon the payment, or one URL per mode withPUT /v1/webhook. - The IPN secret: the webhook signing secret, one per mode
(
GET /v1/webhook). - The signature: an HMAC-SHA256 of
t + "." + raw body, exactly as received: no sorting of keys, no re-serializing.tis a timestamp: reject anything more than 5 minutes old. - The final status:
confirmedis final: fulfil. With direct settlement,settlement.completedtells you when the money reached your wallet. - A sandbox: test mode, with the test key and the test webhook (Test mode).
From Stripe: the t=…,v1=… signature works like Stripe-Signature, with the
event type also in X-Webhook-Event. From Coinbase Commerce: X-CC-Webhook-Signature
was an HMAC-SHA256 of the raw body; payitgo adds the timestamp to what is signed.
Example notifications
One full notification per event type, as your server receives it (indented
here; the real body is one line). Payment events show a live USDT_TRC20
payment with direct settlement.
payment.partially_paid
60 of 100 USDT arrived. The deposit is still confirming ("status": "pending").
{
"created_at": "2026-10-10T10:04:11.208114937Z",
"data": {
"id": "5a9e3c7b1d4f2e8a6c0b9d17",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1002",
"status": "partially_paid",
"deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": true,
"crypto_amount": "100",
"received_amount": "60",
"usd_amount": "100",
"amount_due_usd": "100",
"fees_paid_by_customer": false,
"network_fee": "6.019231",
"network_fee_usd": "6.03",
"settlement": "direct",
"received_usd": "60",
"rate_locked": "1",
"tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0",
"deposits": [
{ "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0", "token": "USDT_TRC20", "chain": "tron", "amount": "60", "usd_value": "60", "counted": true, "status": "pending", "seen_at": "2026-10-10T10:04:11.207Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79413702, "made_at": "2026-10-10T10:04:06Z" }
],
"expires_at": "2026-10-10T10:15:00.405Z",
"created_at": "2026-10-10T10:00:00.405Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"checkout_url": "https://pay.example.com/pay/b28d4f61e9a03c75d2e8f1a6"
},
"id": "9e1a5c3f7b0d2e4a6c8f1b5d",
"livemode": true,
"type": "payment.partially_paid"
}
payment.detecting
The full amount arrived; the deposit is confirming. Show "payment received", but do not ship yet.
{
"created_at": "2026-10-10T09:03:02.419733021Z",
"data": {
"id": "8f3a2c1e9b7d4a6f05e1c2d3",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1001",
"status": "detecting",
"deposit_address": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": true,
"crypto_amount": "49.9",
"received_amount": "49.9",
"usd_amount": "49.9",
"amount_due_usd": "49.9",
"fees_paid_by_customer": false,
"network_fee": "6.019231",
"network_fee_usd": "6.03",
"settlement": "direct",
"received_usd": "49.9",
"rate_locked": "1",
"tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd",
"deposits": [
{ "tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd", "token": "USDT_TRC20", "chain": "tron", "amount": "49.9", "usd_value": "49.9", "counted": true, "status": "pending", "seen_at": "2026-10-10T09:03:02.418Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79412538, "made_at": "2026-10-10T09:02:57Z" }
],
"expires_at": "2026-10-10T09:15:00.112Z",
"created_at": "2026-10-10T09:00:00.112Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
"checkout_url": "https://pay.example.com/pay/6f1e2a9c4d7b3e05a8c1f2d4",
"description": "2 x Acme T-shirt",
"metadata": { "customer_id": "cus_42" },
"customer_email": "jane@example.com",
"success_url": "https://acme.example/thanks",
"cancel_url": "https://acme.example/cart"
},
"id": "7c3e9a1f5b2d8e4c6a0f1b3d",
"livemode": true,
"type": "payment.detecting"
}
payment.confirmed
Final. Fulfil the order.
{
"created_at": "2026-10-10T09:04:03.771240512Z",
"data": {
"id": "8f3a2c1e9b7d4a6f05e1c2d3",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1001",
"status": "confirmed",
"deposit_address": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": true,
"crypto_amount": "49.9",
"received_amount": "49.9",
"usd_amount": "49.9",
"amount_due_usd": "49.9",
"fees_paid_by_customer": false,
"network_fee": "6.019231",
"network_fee_usd": "6.03",
"settlement": "direct",
"received_usd": "49.9",
"rate_locked": "1",
"tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd",
"deposits": [
{ "tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd", "token": "USDT_TRC20", "chain": "tron", "amount": "49.9", "usd_value": "49.9", "counted": true, "status": "confirmed", "seen_at": "2026-10-10T09:03:02.418Z", "confirmed_at": "2026-10-10T09:04:03.77Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79412538, "made_at": "2026-10-10T09:02:57Z" }
],
"expires_at": "2026-10-10T09:15:00.112Z",
"created_at": "2026-10-10T09:00:00.112Z",
"confirmed_at": "2026-10-10T09:04:03.770981245Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
"checkout_url": "https://pay.example.com/pay/6f1e2a9c4d7b3e05a8c1f2d4",
"description": "2 x Acme T-shirt",
"metadata": { "customer_id": "cus_42" },
"customer_email": "jane@example.com",
"success_url": "https://acme.example/thanks",
"cancel_url": "https://acme.example/cart"
},
"id": "2b8f4d6a0c1e3f5a7b9d2c4e",
"livemode": true,
"type": "payment.confirmed"
}
payment.underpaid
The deadline passed with 60 of 100 USDT. The 60 are settled to you; refund the customer or agree on the rest.
{
"created_at": "2026-10-10T10:15:04.551870233Z",
"data": {
"id": "5a9e3c7b1d4f2e8a6c0b9d17",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1002",
"status": "underpaid",
"deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": true,
"crypto_amount": "100",
"received_amount": "60",
"usd_amount": "100",
"amount_due_usd": "100",
"fees_paid_by_customer": false,
"network_fee": "6.019231",
"network_fee_usd": "6.03",
"settlement": "direct",
"received_usd": "60",
"rate_locked": "1",
"tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0",
"deposits": [
{ "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0", "token": "USDT_TRC20", "chain": "tron", "amount": "60", "usd_value": "60", "counted": true, "status": "confirmed", "seen_at": "2026-10-10T10:04:11.207Z", "confirmed_at": "2026-10-10T10:05:12.33Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79413702, "made_at": "2026-10-10T10:04:06Z" }
],
"expires_at": "2026-10-10T10:15:00.405Z",
"created_at": "2026-10-10T10:00:00.405Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"checkout_url": "https://pay.example.com/pay/b28d4f61e9a03c75d2e8f1a6"
},
"id": "4d6b8f0a2c5e7a9c1e3b5d7f",
"livemode": true,
"type": "payment.underpaid"
}
payment.expired
Nothing arrived before the deadline. Captured from a test payment:
{
"created_at": "2026-10-10T08:03:13.937152328Z",
"data": {
"id": "6c4a2e8f0b1d3f5a7c9e2b4d",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1004",
"status": "expired",
"deposit_address": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": false,
"crypto_amount": "49.9",
"received_amount": "0",
"usd_amount": "49.9",
"amount_due_usd": "49.9",
"fees_paid_by_customer": false,
"network_fee": "3.212009",
"network_fee_usd": "3.22",
"settlement": "direct",
"received_usd": "0",
"rate_locked": "1",
"deposits": [],
"expires_at": "2026-10-10T08:02:43.72Z",
"created_at": "2026-10-10T07:47:43.719Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
"description": "2 x Acme T-shirt",
"metadata": { "customer_id": "cus_42" },
"customer_email": "jane@example.com",
"success_url": "https://acme.example/thanks",
"cancel_url": "https://acme.example/cart"
},
"id": "9d4b2f7e1c6a3e08b5d2f1c4",
"livemode": false,
"type": "payment.expired"
}
payment.failed
A deposit was seen but never confirmed: here the network did not confirm it
within 48 hours. The deposit shows "status": "failed" with failed_at and
fail_reason, and no longer counts in received_amount and received_usd.
Treat the order as unpaid.
{
"created_at": "2026-10-12T12:02:31.031662204Z",
"data": {
"id": "b7d9f1a3c5e2b4d6f8a0c1e3",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1006",
"status": "failed",
"deposit_address": "TXp2Rk7mW4vN9sC3hB6jD8fL1qY5tGzEaK",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": true,
"crypto_amount": "25",
"received_amount": "0",
"usd_amount": "25",
"amount_due_usd": "25",
"fees_paid_by_customer": false,
"network_fee": "6.019231",
"network_fee_usd": "6.03",
"settlement": "direct",
"received_usd": "0",
"rate_locked": "1",
"tx_id": "3d363e97717f6fadd803b83f0f30cc49b7e39901b8f3d6c8cdd7dee6d16ba645",
"deposits": [
{ "tx_id": "3d363e97717f6fadd803b83f0f30cc49b7e39901b8f3d6c8cdd7dee6d16ba645", "token": "USDT_TRC20", "chain": "tron", "amount": "25", "usd_value": "25", "counted": true, "status": "failed", "seen_at": "2026-10-10T12:02:30.115Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79415233, "made_at": "2026-10-10T12:02:25Z", "failed_at": "2026-10-12T12:02:31.02Z", "fail_reason": "confirmation timeout" }
],
"expires_at": "2026-10-10T12:15:00.640Z",
"created_at": "2026-10-10T12:00:00.640Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TXp2Rk7mW4vN9sC3hB6jD8fL1qY5tGzEaK",
"checkout_url": "https://pay.example.com/pay/a3c5e7b9d1f4a6c8e0b2d4f6"
},
"id": "8a0c2e4b6d1f3a5c7e9b1d3f",
"livemode": true,
"type": "payment.failed"
}
payment.wrong_asset
The customer sent 150 TRX to a USDT payment. With accept_other_assets off,
the deposit does not count ("counted": false) and the payment keeps waiting
for USDT; the TRX is still settled to you.
{
"created_at": "2026-10-10T11:03:20.612950871Z",
"data": {
"id": "e4c2a0b8d6f1e3a5c7b9d1f3",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1005",
"status": "pending",
"deposit_address": "TQm5Hc8vK2xN7rB4wS9jD6fG3pL1zYtEaV",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": true,
"crypto_amount": "50",
"received_amount": "0",
"usd_amount": "50",
"amount_due_usd": "50",
"fees_paid_by_customer": false,
"network_fee": "6.019231",
"network_fee_usd": "6.03",
"settlement": "direct",
"received_usd": "0",
"rate_locked": "1",
"tx_id": "02d9e46278b55ce8148371de45cd76a0e845897466caa93e997428f21c6540b4",
"deposits": [
{ "tx_id": "02d9e46278b55ce8148371de45cd76a0e845897466caa93e997428f21c6540b4", "token": "TRX", "chain": "tron", "amount": "150", "usd_value": "49.335", "counted": false, "status": "pending", "seen_at": "2026-10-10T11:03:20.611Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79414688, "made_at": "2026-10-10T11:03:15Z" }
],
"expires_at": "2026-10-10T11:15:00.218Z",
"created_at": "2026-10-10T11:00:00.218Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TQm5Hc8vK2xN7rB4wS9jD6fG3pL1zYtEaV",
"checkout_url": "https://pay.example.com/pay/d7b5e3a1c9f2e4b6a8d0c2e4"
},
"id": "1c3e5a7f9b2d4f6a8c0e2b4d",
"livemode": true,
"type": "payment.wrong_asset"
}
payment.late_deposit
The underpaid payment above got its missing 40 USDT 16 minutes after the
deadline. It moves on to detecting (a payment.detecting is sent too) and
will confirm with "paid_late": true.
{
"created_at": "2026-10-10T10:31:45.904377120Z",
"data": {
"id": "5a9e3c7b1d4f2e8a6c0b9d17",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"order_id": "ORD-1002",
"status": "detecting",
"deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"token": "USDT_TRC20",
"chain": "tron",
"livemode": true,
"crypto_amount": "100",
"received_amount": "100",
"usd_amount": "100",
"amount_due_usd": "100",
"fees_paid_by_customer": false,
"network_fee": "6.019231",
"network_fee_usd": "6.03",
"settlement": "direct",
"received_usd": "100",
"rate_locked": "1",
"tx_id": "7463696fcea213c9b884c0d0612f23db02aa413b6b24c59ac11614f899cba0f5",
"paid_late": true,
"deposits": [
{ "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0", "token": "USDT_TRC20", "chain": "tron", "amount": "60", "usd_value": "60", "counted": true, "status": "confirmed", "seen_at": "2026-10-10T10:04:11.207Z", "confirmed_at": "2026-10-10T10:05:12.33Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79413702, "made_at": "2026-10-10T10:04:06Z" },
{ "tx_id": "7463696fcea213c9b884c0d0612f23db02aa413b6b24c59ac11614f899cba0f5", "token": "USDT_TRC20", "chain": "tron", "amount": "40", "usd_value": "40", "counted": true, "late": true, "status": "pending", "seen_at": "2026-10-10T10:31:45.902Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79414015, "made_at": "2026-10-10T10:31:40Z" }
],
"expires_at": "2026-10-10T10:15:00.405Z",
"created_at": "2026-10-10T10:00:00.405Z",
"network_fee_note": "Final in about a minute.",
"qr_payload": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
"checkout_url": "https://pay.example.com/pay/b28d4f61e9a03c75d2e8f1a6"
},
"id": "6f0b2d4a8c1e3a5c7e9b0d2f",
"livemode": true,
"type": "payment.late_deposit"
}
settlement.completed
Your share of the deposit of payment.confirmed above reached your wallet:
49.9 USDT less the 1% service fee and the network fee. A deposit converted on
its way to you also carries its conversion
(coins you have no payout address for),
such as {"via": "exchange", "from": "BTC", "amount_in": "0.0021", "to": "USDT_TRC20", "amount_out": "196.42", "status": "done"},
and its tx_id is the exchange's payout to your wallet.
{
"created_at": "2026-10-10T09:05:12.403118640Z",
"data": {
"id": "0441481512422d5c1e2ce3ab",
"payment_id": "8f3a2c1e9b7d4a6f05e1c2d3",
"deposit_tx": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd",
"livemode": true,
"mode": "direct",
"token": "USDT_TRC20",
"chain": "tron",
"amount": "49.9",
"service_fee": "0.499",
"network_fee": "6.019231",
"conversion_fee": "0",
"net": "43.381769",
"usd_value": "49.9",
"usd_rate": "1",
"service_fee_usd": "0.5",
"network_fee_usd": "6.02",
"conversion_fee_usd": "0",
"net_usd": "43.38",
"destination": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
"tx_id": "16d7a564712e9079cddc178e2a80b8d2b6284574cc5bfeb8272580de9f6cae4e",
"status": "completed",
"created_at": "2026-10-10T09:04:03.81Z",
"completed_at": "2026-10-10T09:05:12.402315877Z"
},
"id": "3e5a7c9f1b4d6a8c0e2f4b6d",
"livemode": true,
"type": "settlement.completed"
}
settlement.failed
Your share could not be sent after repeated tries. payitgo's team retries it, or returns it to your balance.
{
"created_at": "2026-10-10T16:41:09.877201394Z",
"data": {
"id": "7b9d1f3a5c8e0a2c4e6b8d0f",
"payment_id": "5a9e3c7b1d4f2e8a6c0b9d17",
"deposit_tx": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0",
"livemode": true,
"mode": "direct",
"token": "USDT_TRC20",
"chain": "tron",
"amount": "60",
"service_fee": "0.6",
"network_fee": "6.019231",
"conversion_fee": "0",
"net": "53.380769",
"usd_value": "60",
"usd_rate": "1",
"service_fee_usd": "0.6",
"network_fee_usd": "6.02",
"conversion_fee_usd": "0",
"net_usd": "53.38",
"destination": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
"status": "failed",
"created_at": "2026-10-10T10:05:12.34Z"
},
"id": "5a7c9e1b3d6f8a0c2e4b6d8f",
"livemode": true,
"type": "settlement.failed"
}
payout.approved
{
"created_at": "2026-10-10T11:20:41.512937004Z",
"data": {
"id": "7e2c9a4f1b3d8e6a05c2f1d9",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"token": "USDT_TRC20",
"chain": "tron",
"amount": "250",
"address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
"address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
"network_fee": "3.21",
"network_fee_usd": "3.21",
"send_amount": "246.79",
"usd_rate": "1",
"amount_usd": "250",
"send_amount_usd": "246.79",
"status": "approved",
"requested_by": "9b2e4c71d8a05f3e6c1a7b24",
"approved_at": "2026-10-10T11:20:41.5Z",
"created_at": "2026-10-10T10:00:00.31Z",
"updated_at": "2026-10-10T11:20:41.5Z"
},
"id": "0d2f4b6a8c1e3a5c7e9f1b3d",
"livemode": true,
"type": "payout.approved"
}
payout.rejected
{
"created_at": "2026-10-10T11:20:41.512937004Z",
"data": {
"id": "7e2c9a4f1b3d8e6a05c2f1d9",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"token": "USDT_TRC20",
"chain": "tron",
"amount": "250",
"address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
"address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
"network_fee": "3.21",
"network_fee_usd": "3.21",
"send_amount": "246.79",
"usd_rate": "1",
"amount_usd": "250",
"send_amount_usd": "246.79",
"status": "rejected",
"review_note": "the address could not be verified: please add it again",
"requested_by": "9b2e4c71d8a05f3e6c1a7b24",
"rejected_at": "2026-10-10T11:20:41.5Z",
"created_at": "2026-10-10T10:00:00.31Z",
"updated_at": "2026-10-10T11:20:41.5Z"
},
"id": "4b6d8f0a2c5e7a9c1e3f5b7d",
"livemode": true,
"type": "payout.rejected"
}
payout.sent
{
"created_at": "2026-10-10T11:42:03.894411702Z",
"data": {
"id": "7e2c9a4f1b3d8e6a05c2f1d9",
"merchant_id": "3f8a1c27b94e05d6a2c71e90",
"token": "USDT_TRC20",
"chain": "tron",
"amount": "250",
"address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
"address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
"network_fee": "3.21",
"network_fee_usd": "3.21",
"send_amount": "246.79",
"usd_rate": "1",
"amount_usd": "250",
"send_amount_usd": "246.79",
"status": "sent",
"tx_id": "1f6b4e01f2d8debd54a3fc88a7d4494d81477828cbf2cd1e76dd2987ba34d71f",
"requested_by": "9b2e4c71d8a05f3e6c1a7b24",
"approved_at": "2026-10-10T11:20:41.5Z",
"created_at": "2026-10-10T10:00:00.31Z",
"updated_at": "2026-10-10T11:42:03.88Z",
"sent_at": "2026-10-10T11:42:03.88Z"
},
"id": "2f4b6d8a0c3e5a7c9e1b3d5f",
"livemode": true,
"type": "payout.sent"
}
ping
Sent by a test. It goes to the account's URL of the mode.
{
"created_at": "2026-10-10T09:12:44.512837Z",
"data": {
"message": "pong"
},
"id": "5c0e1f9a2b7d4e3c8a6f1b2d",
"livemode": false,
"type": "ping"
}