logo
StartWebHooks

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:

StatusWhen it happens
successCustomer paid and the invoice completed successfully
failPayment flow ended with failure
expiredThe payment window expired
canceledThe 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 null or false, and a reason field 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:

  1. callback_url supplied in the create-invoice request
  2. 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

FieldTypeDescription
event_idstringUnique event identifier. Use it as a deduplication key
order_idstring (UUID)System-generated invoice UUID
external_idstringYour external order ID supplied at invoice creation
statusstringTerminal status: success, fail, expired, canceled
sub_statusstring | nullMore detailed payment result when available
finished_atstring | nullFinal status timestamp
expires_atstring | nullOriginal invoice expiration timestamp
amountstringFinal invoice amount
currencystringCurrency code
rate_usdstring | nullLocked fiat-to-USDT conversion rate when available
success_urlstring | nullCustomer success redirect URL
fail_urlstring | nullCustomer fail redirect URL
is_adjustedbooleantrue if the final amount was changed during dispute resolution
original_amountstring | nullAmount before adjustment
adjusted_amountstring | nullAmount after adjustment
reasonstring | nullPresent 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_adjusted becomes true
  • amount contains the final adjusted amount
  • original_amount contains the original amount
  • adjusted_amount contains 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 amount or adjusted_amount as the final amount in your system
  • Keep original_amount only 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 remains true
  • If no adjustment happened, is_adjusted=false and both original_amount and adjusted_amount are null

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}

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:

AttemptDelay after failure
130 seconds
21 minute
32 minutes
44 minutes
58 minutes
616 minutes
730 minutes

After all automatic retries fail, the event is marked as dead and no further automatic delivery is performed.


Event lifecycle

StatusDescription
pendingThe webhook is queued for delivery or waiting for the next retry
successThe webhook was delivered successfully
deadAll 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 UUID
  • external_id — your order ID supplied at invoice creation
MethodEndpointDescription
GET/api/v1/invoices/{invoice_ref}/webhooksReturns webhook events and delivery attempts for an invoice
POST/api/v1/invoices/{invoice_ref}/webhooks/resendTriggers 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

FieldTypeDescription
successfulbooleanWhether the API request succeeded
invoice_idstring (UUID)Platform invoice order_id
external_idstring | nullYour external invoice/order ID
events_countintegerTotal webhook events generated for this invoice
eventsarrayWebhook events, newest first
events[].event_idintegerInternal webhook event ID
events[].event_typestringEvent type, for example invoice.success, invoice.fail, invoice.expired, or invoice.adjusted
events[].statusstringWebhook delivery status: pending, success, or dead
events[].callback_urlstringURL used for delivery
events[].auto_attemptsintegerNumber of automatic delivery attempts
events[].manual_attemptsintegerNumber of manual resend attempts triggered via API
events[].total_attemptsintegerTotal delivery attempts
events[].next_retry_atstring | nullNext automatic retry time, or null when no retry is scheduled
events[].locked_atstring | nullPresent when the event is currently being processed
events[].locked_bystring | nullWorker/process that currently holds the delivery lock
events[].request_payloadobjectExact JSON payload sent to your callback URL
events[].created_atstring | nullEvent creation timestamp
events[].updated_atstring | nullLast event state update timestamp
events[].attemptsarrayDelivery attempt log ordered by try_number
events[].attempts[].try_numberintegerSequential attempt number
events[].attempts[].triggerstringauto for worker retry, manual for API resend
events[].attempts[].attempt_statusstringsuccess or failure
events[].attempts[].http_statusinteger | nullHTTP status returned by your endpoint
events[].attempts[].response_headersobject | nullResponse headers returned by your endpoint
events[].attempts[].response_bodystring | nullResponse body stored for the attempt
events[].attempts[].error_messagestring | nullNetwork/timeout/error details for failed attempts
events[].attempts[].duration_msinteger | nullDelivery round-trip time in milliseconds
events[].attempts[].created_atstring | nullAttempt 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, or dead status
  • 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
ErrorHTTP statusWhen it happens
INVOICE_NOT_FOUND404Invoice does not exist or does not belong to the authenticated merchant
WEBHOOK_NOT_FOUND404No webhook events have been generated for this invoice yet
WEBHOOK_RESEND_COOLDOWN429Manual resend was called too soon for the same event
WEBHOOK_RESEND_CONFLICT409Another 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 amount as the source of truth when is_adjusted=true
  • Respond within 15 seconds
  • Deduplicate events using event_id or X-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