PayGrid delivers two JSON payload shapes depending on which URL received the notification. Both are signed identically — see Verify signatures.
For payment status definitions and the lifecycle diagram, see Track payments.
Payment webhook events
Webhooks fire on every status transition — not at creation time. There is no payment.pending webhook: pending is the initial state, so the first webhook is the first transition (usually a terminal status). The event field is {transaction_type}.{new_status}:
| Event | data.status | Typical data.previous_status | When it fires |
|---|
payment.processing | processing | pending | Rare — not a standard step for mobile payments |
payment.completed | completed | pending | Payment captured successfully |
payment.failed | failed | pending | Payment was declined or rejected |
payment.cancelled | cancelled | pending | Payment cancelled |
payment.expired | expired | pending | Payment not completed within ~30 minutes |
No webhook is emitted for refund statuses (partially_refunded, refunded). Don’t wait for payment.partially_refunded or payment.refunded events.
Deduplicate with event_id — one per status transition. Retries reuse the same event_id (only X-MeetPay-Delivery-ID changes per attempt).
Payout webhook events
Payouts use merchant default URLs (captured at payout creation — not overridable per payout). Events follow the same {type}.{status} pattern. As with payments, there is no webhook at payout creation (pending is the initial state).
| Event | When it fires |
|---|
payout.processing | Provider accepted the payout and it is in flight |
payout.completed | Funds delivered to the recipient |
payout.failed | Disbursement failed |
Callbacks fire once when a payout reaches a terminal status (completed, failed, and other terminal states such as voided where applicable).
Test event
Dashboard Test webhook sends:
| Event | Purpose |
|---|
webhook.test | Signature and connectivity check — transaction_id is all zeros; data.status is "test" |
Webhook payload
Sent to webhook_url on each status change:
{
"version": "1.0",
"event": "payment.completed",
"event_id": "d4f8a1b2-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
"transaction_id": "f5d238bd-f8ab-4379-9832-0f1ce6d65cbe",
"merchant_reference": "ORDER_123",
"data": {
"amount": 5000,
"currency": "TZS",
"status": "completed",
"previous_status": "pending",
"payment_type": "mobile",
"provider_reference": "EXT-20250101-001",
"completed_at": "2026-06-09T12:54:05Z"
},
"metadata": {
"order_id": "ORD-9876"
},
"timestamp": "2026-06-09T12:54:06Z"
}
Optional fields (failure_reason, completed_at, metadata) are omitted when empty — they are never sent as null.
Webhook fields
| Field | Type | Description |
|---|
version | string | Payload schema version ("1.0") |
event | string | Event name, e.g. payment.completed |
event_id | UUID | Unique per event (status transition); stable across retries — use for deduplication |
transaction_id | UUID | PayGrid payment ID (same as GET /api/v1/payments/{id}) |
merchant_reference | string | Your reference from payment creation (always present on webhooks; may be an empty string) |
data.amount | number | Original payment amount |
data.currency | string | Currency code (TZS, USD, KES, UGX) |
data.status | string | New status after this transition |
data.previous_status | string | Status before this transition |
data.payment_type | string | mobile, card, or dynamic-qr |
data.provider_reference | string | Upstream payment reference — quote it in support requests |
data.failure_reason | string | Only on payment.failed when a reason is available; omitted otherwise |
data.completed_at | datetime | Present once the payment reaches a terminal status; omitted otherwise |
metadata | object | Your original metadata echoed back; omitted when empty |
timestamp | datetime | When PayGrid generated this notification |
Example: failure event
{
"version": "1.0",
"event": "payment.failed",
"event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"transaction_id": "f5d238bd-f8ab-4379-9832-0f1ce6d65cbe",
"merchant_reference": "ORDER_123",
"data": {
"amount": 5000,
"currency": "TZS",
"status": "failed",
"previous_status": "pending",
"payment_type": "mobile",
"provider_reference": "EXT-20250101-001",
"failure_reason": "Customer declined USSD prompt",
"completed_at": "2026-06-09T12:54:11Z"
},
"timestamp": "2026-06-09T12:54:12Z"
}
Callback payload
Optional channel — only sent if you configured callback_url. Most integrations use webhook_url only and never receive callbacks.
Sent to callback_url once when the payment reaches a terminal status. Simpler shape — no event or previous_status:
{
"version": "1.0",
"type": "transaction.callback",
"transaction_id": "f5d238bd-f8ab-4379-9832-0f1ce6d65cbe",
"merchant_reference": "ORDER_123",
"amount": 5000,
"currency": "TZS",
"status": "completed",
"payment_type": "mobile",
"provider_reference": "EXT-20250101-001",
"completed_at": "2026-06-09T12:54:05Z",
"metadata": {
"order_id": "ORD-9876"
},
"timestamp": "2026-06-09T12:54:06Z"
}
As with webhooks, failure_reason (failed only), completed_at, and metadata are omitted when empty; in callbacks merchant_reference is also omitted when empty. Deduplicate callbacks with transaction_id (one callback per payment).
Webhook vs callback
| Aspect | Webhook | Callback |
|---|
| Fires on | Every status change | Terminal states only |
| Frequency | Multiple times per transaction | Once per transaction |
| Deduplication key | event_id | transaction_id |
Includes previous_status | Yes | No |
| Event field | event (e.g. payment.completed) | type: "transaction.callback" |
See Handle events for processing guidance.
Timestamps in payloads (timestamp, completed_at) are ISO 8601 UTC strings from Go’s JSON encoder (e.g. "2026-06-09T12:54:06Z").
Back to start: Overview