The Orchestrator exposes that external providers call to notify it about events. These are endpoints — you never register or call them as an integrator.
OFFER-HUB does provide an outbound webhook subscription API. There is no POST /api/v1/webhooks endpoint for registering a URL, an event list, or a signing secret. The only real-time mechanism for integrators is the — see the Events reference for subscriptions and the event catalog. The two /webhooks endpoints below are inbound receivers used by external providers.
Common behavior
Both receivers live in WebhooksController (apps/api/src/modules/webhooks/webhooks.controller.ts). Because of the global /api/v1 prefix (apps/api/src/main.ts), the full paths are POST /api/v1/webhooks/airtm and POST /api/v1/webhooks/trustless-work.
For both endpoints:
- An invalid signature is rejected with
401 before any processing happens.
- Every provider event is stored once in the
WebhookEvent table, keyed by (provider, providerEventId). A repeat of an already-processed event returns 200 with duplicate: true instead of being applied twice.
- , even for business-logic failures (
processed: false) — acknowledging avoids provider retry storms for states the Orchestrator already handles.
- and covered by the
AIRTM_WEBHOOK_SECRET / TRUSTLESS_WEBHOOK_SECRET environment variables documented in the Configuration guide.
AirTM — POST /api/v1/webhooks/airtm
Receives pay-in (topup.*) and pay-out (withdrawal.*) status updates from AirTM.
The Orchestrator verifies the request with the standard headers and your AIRTM_WEBHOOK_SECRET:
| Header | Purpose |
|---|
svix-id | Unique message identifier |
svix-timestamp | Unix timestamp of the message |
svix-signature | HMAC-SHA256 signature over the raw body |
Verification uses svix.Webhook.verify(rawBody, headers) (apps/api/src/providers/airtm/services/airtm-webhook.service.ts, configured in apps/api/src/providers/airtm/airtm.config.ts). If AIRTM_WEBHOOK_SECRET is not set, verification is as a warning — acceptable for local development, never for production.
AirTM sends the payload in the AirtmWebhookPayload shape (apps/api/src/providers/airtm/types/airtm.types.ts):
{
"eventId": "evt_8yumeOFwqPnSbhvemqJ",
"eventType": "payin.succeeded",
"occurredAt": "2026-02-20T14:30:00.000Z",
"data": {
"id": "payin_789",
"code": "PX-1001",
"amount": 50,
"currency": "USD",
"status": "SUCCEEDED",
"reasonCode": null,
"reasonDescription": null
}
}
| Field | Description |
|---|
eventId | Unique event ID from AirTM. |
eventType | One of the payin.* or payout.* webhook event types below. |
occurredAt | ISO 8601 timestamp of the event. |
data | The affected pay-in/pay-out with the provider's internal id. AirTM data.id maps the event to your topup/withdrawal. |
Accepted eventType values:
-
payin.created, payin.awaiting_user_confirmation, payin.processing, payin.succeeded, payin.failed, payin.canceled
-
payout.created, payout.committed, payout.pending, payout.pending_user_action, payout.completed, payout.failed, payout.canceled
The Orchestrator looks up the WebhookEvent row with (provider = AIRTM, providerEventId = eventId). A duplicate returns 200 { status: "ok", duplicate: true } and is ignored. Beyond that, top-ups and withdrawals already in a terminal state are skipped (isTerminalPayinStatus / isTerminalPayoutStatus), and invalid state transitions are logged but not failed — the webhook data is treated as authoritative (apps/api/src/providers/airtm/services/airtm-webhook.service.ts).
| Case | HTTP | Body |
|---|
| Processed for the first time | 200 | { "status": "ok", "processed": true } |
| Duplicate delivery | 200 | { "status": "ok", "duplicate": true } |
| Valid signature, business failure (e.g. related top-up not found) | 200 | { "status": "ok", "processed": false } |
| Invalid or missing signature | 401 | Standard error envelope |
Trustless Work — POST /api/v1/webhooks/trustless-work
Receives escrow lifecycle events from Trustless Work and flips the escrow/order state machines (re-emitting the corresponding domain events on the SSE stream).
The Orchestrator verifies the x-tw-signature header — a hex digest of the raw request body keyed with TRUSTLESS_WEBHOOK_SECRET (apps/api/src/providers/trustless-work/services/webhook.service.ts, configured in apps/api/src/providers/trustless-work/trustless-work.config.ts):
x-tw-signature: HMAC-SHA256(rawBody, TRUSTLESS_WEBHOOK_SECRET)
The config requires the secret to start with tw_whsec_. A mismatched signature throws 401 with error code WEBHOOK_SIGNATURE_INVALID. Trustless Work requests sent the header are logged and processed anyway — configure the secret and require the header in production.
Validated by TrustlessWebhookDto (apps/api/src/providers/trustless-work/dto/webhook.dto.ts):
{
"type": "escrow.funded",
"event_id": "evt_trustless_123",
"data": {
"contract_id": "CTW4Q6N...",
"order_id": "ord_abc123",
"status": "FUNDED",
"amount": "500000000",
"currency": "USDC",
"buyer_address": "GBCG42WTVWPO4Q6N...",
"seller_address": "GBRTW2CXBA7ZQBCN...",
"transaction_hash": "abcdef0123...",
"milestone_ref": "M1",
"created_at": "2026-02-20T12:00:00.000Z",
"updated_at": "2026-02-20T12:05:00.000Z"
}
}
data.contract_id is the Stellar escrow contract address; the Orchestrator matches it to the escrow it created through trustlessContractId. amount is expressed in stroops.
Accepted type values drive the escrow lifecycle:
escrow.created → stores the contract ID and moves the order to ESCROW_FUNDING
escrow.funding_started → updates the escrow status
escrow.funded → moves the order to IN_PROGRESS (with transaction_hash verification)
escrow.milestone_completed → records the milestone
escrow.released → confirms release and credits the seller
escrow.refunded → confirms refund and credits the buyer
escrow.disputed → marks the escrow as DISPUTED
On-chain events carrying a transaction_hash are verified against the Stellar network before the state change is applied (verifyTransactionIfPresent, apps/api/src/providers/trustless-work/services/webhook.service.ts).
The Orchestrator looks up the WebhookEvent row with (provider = TRUSTLESS_WORK, providerEventId = event_id). A duplicate returns 200 { status: "ok", processed: true } without re-applying the state change.
| Case | HTTP | Body |
|---|
| Processed for the first time | 200 | { "status": "ok", "processed": true } |
| Duplicate delivery | 200 | { "status": "ok", "processed": true } |
| Invalid signature | 401 | WEBHOOK_SIGNATURE_INVALID error envelope |
Self-hosting
Both receivers run inside your Orchestrator instance — no outbound configuration or public exposure beyond the provider's configured callback URL is required. See the Self-Hosting guide for deployment, and the Configuration guide for the AIRTM_WEBHOOK_SECRET and TRUSTLESS_WEBHOOK_SECRET environment variables.
Next Steps
- Events (SSE) — real-time event stream, reconnection, and the event catalog emitted by these inbound flows
- Configuration — webhook secrets and provider settings
- Self-Hosting — deploy the Orchestrator and receive webhooks
- Escrow Flows — lifecycle events driven by Trustless Work webhooks