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"}'

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

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 ) )
  1. 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.
  2. Split the header on , and each part on the first =: take t and v1. If either is missing, reject.
  3. 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.
  4. Compare it with v1 in constant time. If they differ, reject.
  5. Reject if t is 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 current t.
  6. 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

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

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

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"
}