Webhooks
Receive real-time notifications when an invoice reaches a terminal state. Learn about payload structure, HMAC signature verification, the is_adjusted amount-adjustment flow, retry policy, webhook history API, manual resend API, and best practices.
How it works
Invoice reaches a terminal state
The invoice transitions to a final status — success, fail, expired, or canceled.
Webhooks fire only on terminal transitions, not on every intermediate state change.
Signed POST request is sent
A JSON payload is sent to your callback_url with three headers:
X-Signature, X-Signature-Timestamp, and X-Idempotency-Key.
Your server responds 2xx
Return any 2xx status code within 15 seconds.
Any other response, connection error, or timeout marks the attempt as failed and triggers automatic retries.
When webhooks fire
A webhook is created for each of the following invoice transitions:
| Status | When it happens |
|---|---|
success | Customer paid and the invoice completed successfully |
fail | Payment flow ended with failure |
expired | The payment window expired |
canceled | The invoice was canceled |
There are two payload variants depending on whether the customer had already selected a payment method:
- If a payment method was selected, the payload includes deal-related fields such as
sub_status,rate_usd, and adjustment fields. - If the invoice expired or was canceled before method selection, deal-related fields are
nullorfalse, and areasonfield is included.
Both variants use the same top-level structure, so one parser on your side is enough.
Webhook URL priority
The system uses the destination URL in this order:
callback_urlsupplied in thecreate-invoicerequest- Default webhook URL configured for the merchant account
If neither is set, no webhook notification is sent.
Payload
Sent as POST with Content-Type: application/json:
{
"event_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479:success:1714237200",
"order_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"external_id": "order-2026-0001",
"status": "success",
"sub_status": "successfully_paid",
"finished_at": "2026-04-27T14:35:00+00:00",
"expires_at": "2026-04-27T14:50:00+00:00",
"amount": "1500.00",
"currency": "UAH",
"rate_usd": "41.20",
"success_url": "https://ecca-ex.com/order/2026-0001/success",
"fail_url": "https://ecca-ex.com/order/2026-0001/fail",
"is_adjusted": false,
"original_amount": null,
"adjusted_amount": null
}
For pre-method-selection events, the payload additionally contains "reason": "ttl_expired" or "user_canceled", and sub_status, rate_usd, original_amount, and adjusted_amount are null.
Payload fields
| Field | Type | Description |
|---|---|---|
event_id | string | Unique event identifier. Use it as a deduplication key |
order_id | string (UUID) | System-generated invoice UUID |
external_id | string | Your external order ID supplied at invoice creation |
status | string | Terminal status: success, fail, expired, canceled |
sub_status | string | null | More detailed payment result when available |
finished_at | string | null | Final status timestamp |
expires_at | string | null | Original invoice expiration timestamp |
amount | string | Final invoice amount |
currency | string | Currency code |
rate_usd | string | null | Locked fiat-to-USDT conversion rate when available |
success_url | string | null | Customer success redirect URL |
fail_url | string | null | Customer fail redirect URL |
is_adjusted | boolean | true if the final amount was changed during dispute resolution |
original_amount | string | null | Amount before adjustment |
adjusted_amount | string | null | Amount after adjustment |
reason | string | null | Present only when the invoice ended before payment method selection |
Adjusted amount flow
In some dispute cases, the final invoice amount can be reduced during manual resolution.
When this happens:
is_adjustedbecomestrueamountcontains the final adjusted amountoriginal_amountcontains the original amountadjusted_amountcontains the new final amount
Example adjusted webhook
{
"event_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479:success:1714237200",
"order_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"external_id": "order-2026-0001",
"status": "success",
"sub_status": "completed_with_adjustment",
"amount": "1200.00",
"currency": "UAH",
"is_adjusted": true,
"original_amount": "1500.00",
"adjusted_amount": "1200.00",
"finished_at": "2026-04-27T14:35:00+00:00",
"rate_usd": "41.20"
}
What your backend should do
Treat amount as the source of truth. When is_adjusted=true, the invoice should be handled using the adjusted amount.
- Use
amountoradjusted_amountas the final amount in your system - Keep
original_amountonly for reference or audit purposes - Update your local order amount if you store it in your database
Adjustment guarantees
- Adjustments only decrease the amount
- A deal can be adjusted at most once
- Once
is_adjusted=true, it remainstrue - If no adjustment happened,
is_adjusted=falseand bothoriginal_amountandadjusted_amountarenull
Signature verification
Every webhook request includes three headers:
X-Signature: 7c5c2c8a4f9d3e1b...
X-Signature-Timestamp: 1714237200
X-Idempotency-Key: f47ac10b-...:success:1714237200
The signature is computed as:
signature = HMAC_SHA256(
key = <your active API key secret>,
data = f"{X-Signature-Timestamp}.{raw_request_body}",
).hexdigest()
To verify it, recompute the HMAC on your side and compare it with the header in constant time.
Verify the signature using the raw request body, not re-serialized JSON.
Verification examples
These are example implementation patterns only. How you structure verification and webhook handling in your codebase is entirely up to you.
Timestamp validation is optional for webhooks and we deliberately omit it from these
examples. Auto-retries can arrive up to ~30 minutes after the event; a manual resend can
arrive days later. A strict skew check would cause you to drop legitimate deliveries.
Replay protection is handled by event_id deduplication, not by a freshness window.
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
API_SECRET = os.environ["MERCHANT_API_SECRET"].encode()
@app.post("/webhooks/payment")
async def handle_webhook(request: Request):
signature = request.headers.get("X-Signature", "")
ts = request.headers.get("X-Signature-Timestamp", "")
event_id = request.headers.get("X-Idempotency-Key", "")
if not signature or not ts or not event_id:
raise HTTPException(403, "Missing signature headers")
raw = await request.body()
expected = hmac.new(
API_SECRET,
f"{ts}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(403, "Bad signature")
if await already_processed(event_id):
return {"ok": True}
payload = await request.json()
await enqueue_for_processing(event_id, payload)
return {"ok": True}
import crypto from "node:crypto";
import express from "express";
const app = express();
const API_SECRET = process.env.MERCHANT_API_SECRET;
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["x-signature"] ?? "";
const ts = req.headers["x-signature-timestamp"] ?? "";
const eventId = req.headers["x-idempotency-key"] ?? "";
if (!signature || !ts || !eventId) {
return res.status(403).json({ error: "Missing signature headers" });
}
const expected = crypto
.createHmac("sha256", API_SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).json({ error: "Bad signature" });
}
if (await alreadyProcessed(eventId)) {
return res.json({ ok: true });
}
const payload = JSON.parse(req.body.toString("utf8"));
await enqueueForProcessing(eventId, payload);
res.json({ ok: true });
},
);
<?php
$apiSecret = getenv('MERCHANT_API_SECRET');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$ts = $_SERVER['HTTP_X_SIGNATURE_TIMESTAMP'] ?? '';
$eventId = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? '';
if ($signature === '' || $ts === '' || $eventId === '') {
http_response_code(403);
exit(json_encode(['error' => 'Missing signature headers']));
}
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $ts . '.' . $raw, $apiSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit(json_encode(['error' => 'Bad signature']));
}
if (already_processed($eventId)) {
echo json_encode(['ok' => true]);
exit;
}
$payload = json_decode($raw, true);
enqueue_for_processing($eventId, $payload);
echo json_encode(['ok' => true]);
Optional: timestamp freshness window
If your security model genuinely requires it, you can add a freshness check — but pick a window much larger than the retry schedule, otherwise legitimate retries get dropped:
import time
MAX_SKEW_SEC = 24 * 60 * 60
if abs(time.time() - int(ts)) > MAX_SKEW_SEC:
raise HTTPException(403, "Stale timestamp")
For most merchants the safer choice is to skip this check entirely and rely on
event_id deduplication.
Retry policy
If delivery fails because of a non-2xx response, connection error, or timeout, the system retries automatically.
There are 7 automatic retry attempts with escalating delays:
| Attempt | Delay after failure |
|---|---|
| 1 | 30 seconds |
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 4 minutes |
| 5 | 8 minutes |
| 6 | 16 minutes |
| 7 | 30 minutes |
After all automatic retries fail, the event is marked as dead and no further automatic delivery is performed.
Event lifecycle
| Status | Description |
|---|---|
pending | The webhook is queued for delivery or waiting for the next retry |
success | The webhook was delivered successfully |
dead | All automatic retry attempts were exhausted |
Webhook management API
Use the webhook management API to inspect delivery history and manually resend the latest webhook event for an invoice.
All endpoints require your merchant API key:
X-Api-Key: <your_merchant_api_key>
invoice_ref can be either:
order_id— platform invoice UUIDexternal_id— your order ID supplied at invoice creation
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/invoices/{invoice_ref}/webhooks | Returns webhook events and delivery attempts for an invoice |
POST | /api/v1/invoices/{invoice_ref}/webhooks/resend | Triggers an immediate manual resend for the latest webhook event |
Get webhook history
GET /api/v1/invoices/order-2026-0001/webhooks HTTP/1.1
Host: api.ecca-ex.com
X-Api-Key: <your_merchant_api_key>
Example response:
{
"successful": true,
"invoice_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"external_id": "order-2026-0001",
"events_count": 1,
"events": [
{
"event_id": 123,
"event_type": "invoice.success",
"status": "success",
"callback_url": "https://ecca-ex.com/webhooks/payment",
"auto_attempts": 1,
"manual_attempts": 0,
"total_attempts": 1,
"next_retry_at": null,
"locked_at": null,
"locked_by": null,
"request_payload": {
"event_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479:success:1714237200",
"order_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"external_id": "order-2026-0001",
"status": "success",
"sub_status": "successfully_paid",
"amount": "1500.00",
"currency": "UAH",
"is_adjusted": false
},
"created_at": "2026-04-27T14:35:00+00:00",
"updated_at": "2026-04-27T14:35:02+00:00",
"attempts": [
{
"try_number": 1,
"trigger": "auto",
"attempt_status": "success",
"http_status": 200,
"response_headers": {
"content-type": "application/json"
},
"response_body": "{"ok":true}",
"error_message": null,
"duration_ms": 184,
"created_at": "2026-04-27T14:35:02+00:00"
}
]
}
]
}
Webhook history fields
| Field | Type | Description |
|---|---|---|
successful | boolean | Whether the API request succeeded |
invoice_id | string (UUID) | Platform invoice order_id |
external_id | string | null | Your external invoice/order ID |
events_count | integer | Total webhook events generated for this invoice |
events | array | Webhook events, newest first |
events[].event_id | integer | Internal webhook event ID |
events[].event_type | string | Event type, for example invoice.success, invoice.fail, invoice.expired, or invoice.adjusted |
events[].status | string | Webhook delivery status: pending, success, or dead |
events[].callback_url | string | URL used for delivery |
events[].auto_attempts | integer | Number of automatic delivery attempts |
events[].manual_attempts | integer | Number of manual resend attempts triggered via API |
events[].total_attempts | integer | Total delivery attempts |
events[].next_retry_at | string | null | Next automatic retry time, or null when no retry is scheduled |
events[].locked_at | string | null | Present when the event is currently being processed |
events[].locked_by | string | null | Worker/process that currently holds the delivery lock |
events[].request_payload | object | Exact JSON payload sent to your callback URL |
events[].created_at | string | null | Event creation timestamp |
events[].updated_at | string | null | Last event state update timestamp |
events[].attempts | array | Delivery attempt log ordered by try_number |
events[].attempts[].try_number | integer | Sequential attempt number |
events[].attempts[].trigger | string | auto for worker retry, manual for API resend |
events[].attempts[].attempt_status | string | success or failure |
events[].attempts[].http_status | integer | null | HTTP status returned by your endpoint |
events[].attempts[].response_headers | object | null | Response headers returned by your endpoint |
events[].attempts[].response_body | string | null | Response body stored for the attempt |
events[].attempts[].error_message | string | null | Network/timeout/error details for failed attempts |
events[].attempts[].duration_ms | integer | null | Delivery round-trip time in milliseconds |
events[].attempts[].created_at | string | null | Attempt timestamp |
If the invoice exists but no webhook has been generated yet, the API returns 200
with events_count=0 and events=[].
Resend latest webhook via API
POST /api/v1/invoices/order-2026-0001/webhooks/resend HTTP/1.1
Host: api.ecca-ex.com
X-Api-Key: <your_merchant_api_key>
Example response:
{
"successful": true,
"invoice_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"event_id": 123,
"resend": {
"ok": true,
"http_status": 200,
"duration_ms": 176,
"attempt_id": 456
}
}
Resend behavior
- Resends the latest webhook event for the invoice
- Works for events in
pending,success, ordeadstatus - Creates a new delivery attempt with
trigger=manual - Does not modify the automatic retry schedule
- A successful manual resend changes the event status to
success - A cooldown is enforced between manual resend attempts for the same event
| Error | HTTP status | When it happens |
|---|---|---|
INVOICE_NOT_FOUND | 404 | Invoice does not exist or does not belong to the authenticated merchant |
WEBHOOK_NOT_FOUND | 404 | No webhook events have been generated for this invoice yet |
WEBHOOK_RESEND_COOLDOWN | 429 | Manual resend was called too soon for the same event |
WEBHOOK_RESEND_CONFLICT | 409 | Another delivery attempt is currently processing this event |
You can also resend webhooks from the merchant dashboard. The API endpoint is recommended when you want to automate support tools, reconciliation jobs, or merchant-side recovery flows.
Timeout
Each delivery attempt has a 15-second timeout.
If your endpoint does not return a response within that time, the attempt is marked as failed and retried automatically.
Return 2xx as soon as possible and process the webhook asynchronously on your side.
Best practices
- Verify the signature using the raw request body and constant-time comparison
- Reject requests with missing, malformed, or invalid signature data
- Treat
amountas the source of truth whenis_adjusted=true - Respond within 15 seconds
- Deduplicate events using
event_idorX-Idempotency-Key - Use the webhook history API to inspect delivery attempts and callback responses
- Use the resend API when you need to trigger the latest webhook again
- Log request headers and raw request body for easier debugging
Last updated today
Built with Documentation.AI