Webhooks
Implement reliable StableOps Webhooks with HMAC signature verification, event deduplication, automatic retries, dead-letter handling, and safe replay.
Webhooks allow your application to receive real-time notifications when events occur in StableOps, such as when a payment is detected, confirmed, or finalized.
Overview
Instead of polling the API to check payment status, StableOps sends HTTP POST requests to your server when events happen. This enables:
- Real-time updates: Know immediately when payments arrive
- Reduced API calls: No need to poll for status changes
- Reliable delivery: Automatic retries with exponential backoff
- Event history: All webhook deliveries are logged and can be replayed
How Webhooks Work
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Blockchain │────────▶│ StableOps │────────▶│ Your App │
└─────────────┘ └─────────────┘ └─────────────┘
Payment sent Event detected Webhook sent- An event occurs (e.g., payment detected on blockchain)
- StableOps creates a webhook delivery
- HTTP POST request is sent to your endpoint
- Your server processes the event and returns 200 OK
- If delivery fails, StableOps retries with exponential backoff
Webhook Events
Payment Events
payment_order.created
Sent when a new payment order is created.
payment.detected
Sent when a payment transaction is first seen by the scanner. This can lag the chain head, so don't assume exactly 0 confirmations at this point.
Action: Update UI to show "Payment received, confirming..."
Warning: Do not fulfill the order yet - the transaction could still be reverted.
payment.confirmed
Sent when the payment reaches the configured initial confirmation threshold for its chain. A reorg or execution failure can still move it to reverted.
Action: Safe to update internal systems and notify the user, but consider waiting for payment.finalized before releasing goods.
payment.finalized
Sent when the payment reaches StableOps’ configured final confirmation depth for its chain. This is a terminal product state, not a guarantee of protocol-level or L1 finality; residual chain, RPC, and operational risks remain.
Action: Fulfill after signature verification, deduplication, and matching the business order. Apply your own risk controls to high-value or irreversible actions.
This is the recommended event to trigger order fulfillment.
payment.expired
Sent when an order passes its expiration time without matching any transfer.
Action: Mark the order as expired and prompt the user to start over.
payment.reverted
Sent when a previously detected payment is rolled back. The receipt failed, or after a reorg the block hash no longer matches the stored event.
Action: Undo any optimistic handling done on payment.detected /
payment.confirmed. This is why we recommend fulfilling on payment.finalized.
payment_order.canceled
Sent when an order still in created (before any deposit is detected) is canceled
via the cancel endpoint.
Action: Mark the order as canceled and release any resources reserved for it.
Note: Even though cancellation is caller-initiated and the HTTP response already returns the result, this event is still emitted, so integrators that treat the webhook stream as the source of truth (event sourcing) never miss the terminal transition. Endpoints that don't care can simply ignore it.
See the Payment Events API reference for the full payload schemas.
Merchant Subscription Events
StableOps also emits events for merchant-managed end-user subscriptions and their invoices. Use these events to activate accounts, track renewals, suspend overdue users, and reconcile invoice payment outcomes.
| Event | Trigger |
|---|---|
end_user_subscription.created | A merchant subscription was created |
end_user_subscription.activated | The first invoice was paid and the subscription became active |
end_user_subscription.renewed | A renewal invoice was paid and the billing period advanced |
end_user_subscription.canceled | The subscription was canceled immediately |
end_user_subscription.expired | The subscription expired after cancellation or unpaid invoices |
end_user_subscription.past_due | The subscription entered the past-due state |
end_user_invoice.open | A new subscription invoice was issued |
end_user_invoice.paid | A subscription invoice was paid |
end_user_invoice.payment_failed | A subscription invoice payment failed |
end_user_invoice.payment_late | A payment arrived after the invoice was already uncollectible |
Subscription invoice checkout still creates payment orders, so you may also
receive the normal payment events for the underlying order. Treat the
end_user_invoice.* and end_user_subscription.* events as the source of truth
for subscription state.
See the Subscription Events API reference for the full payload schemas.
Operational & Agent Events
Beyond payment events, StableOps also pushes the following. In the dashboard these form the Operational events subscription group. A single toggle covers all of them, separate from the payment-events group:
| Event | Trigger |
|---|---|
address.pool.low | The available receiving-address pool for a chain is low |
agent.action.requested | An agent requested a write action that needs approval |
agent.action.approved | A pending agent action was approved |
agent.action.executed | An approved agent action finished executing |
See the Operational Events API reference for the full payload schemas.
Setting Up Webhooks
1. Create an Endpoint
In your StableOps dashboard:
- Go to Webhooks in the sidebar
- Click Add Endpoint
- Enter your webhook URL (use HTTPS in production for security)
- Select which events to receive
- Save the webhook secret
The secret field is returned only on create and rotate. Store it
somewhere durable; the dashboard will not show it again. After you call
rotate-secret, the previous secret stays valid alongside the new one for
24 hours, so you can roll out the new value without dropping deliveries.
2. Verify Signatures
Always verify webhook signatures to ensure requests are from StableOps.
import { SIGNATURE_HEADER, verifySignature } from '@stableops/api-sdk/webhooks'
const result = verifySignature({
secrets: [webhookSecret],
header: request.headers.get(SIGNATURE_HEADER) ?? undefined,
rawBody,
})
if (!result.ok) {
console.error('Invalid signature:', result.reason)
// Reject the request
}Delivery, retries, and the dead letter queue
Success criteria
Any 2xx response is treated as success. Any other status code, a network
error, or no response within 10 seconds counts as failure.
Retry schedule
Failed deliveries are rescheduled on this curve before moving to the dead letter queue on attempt 6:
| Attempt | Delay |
|---|---|
| 1 | 30 s |
| 2 | 60 s |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
| 6+ | DLQ |
Delivery log fields
Key fields you'll see on webhook-deliveries:
| Field | Description |
|---|---|
attempts | Number of HTTP attempts performed |
response_status | Downstream status code on the most recent attempt |
response_duration_ms | How long the last attempt took |
error_message | Short, log-friendly reason for the most recent failure |
next_retry_at | When the worker will retry (null once delivery is terminal) |
status | pending, in_progress, succeeded, failed, dead_letter |
Replay
The three replay endpoints (endpoint replay, single-delivery replay, and replay-dead-letters) all enqueue a brand-new delivery row. The original audit log is preserved. The dashboard's "replay" button is just these endpoints under the hood.
Duplicates, ordering, and concurrency
Network retries and manual replay can deliver an event more than once. Different events may arrive concurrently or out of order after retries. Endpoint concurrency limits do not guarantee serial delivery per order. in_progress means the platform is attempting delivery, not that your business processing has completed.
Never let a late payment.detected overwrite a completed order. Use transactions, conditional updates, or serialized work per business order and apply valid state transitions rather than arrival order. Retrieve the current Payment Order when events conflict. For subscriptions, also retrieve the invoice and subscription instead of granting access from the underlying payment event alone.
Deduplicate events with X-Event-Id; use X-Delivery-Id to trace a delivery record. Replay creates a new delivery record, so deduplicating only by delivery ID does not prevent repeated fulfillment.
Best Practices
1. Always Verify Signatures
Reject any request that doesn't carry a valid X-Product-Signature. Without
verification, anyone who discovers your endpoint URL can forge events.
2. Return 200 Quickly
After verification, durably save the event body, X-Event-Id, and recoverable work in a database or persistent queue before returning 200. Return a non-2xx response if persistence fails so delivery can retry; an in-memory task is not enough. Run fulfillment, notifications, and ledger updates in a retryable background worker.
3. Process Idempotently
Enforce uniqueness on X-Event-Id and distinguish received from processed events. Commit business updates and the processed marker in one database transaction. For external effects, use persistent tasks, stable idempotency keys, and recoverable processing states. Marking an event processed before fulfillment can cause retries to skip work that failed halfway through.
4. Use Raw Body for Verification
Compute the signature over the exact bytes you received, before any JSON parsing or re-serialization. Frameworks that parse and re-stringify the body change it and break the signature.
5. Handle All Event Types
Subscribe to and handle every event you might receive, including
payment.reverted, payment.expired, and subscription events such as
end_user_invoice.paid / end_user_subscription.renewed. Unhandled events are
still counted as successful deliveries but your order and subscription tracking
will drift.
6. Log Everything
Record each delivery's X-Event-Id, X-Delivery-Id, event type, and your
processing result. Good logs are the fastest way to debug missed or duplicate
fulfillment.
7. Monitor Delivery Health
Check the delivery log for endpoints with a rising failed or dead_letter
count. Set up alerts for endpoints that haven't returned 2xx in the last
hour, and use the replay endpoint to drain the DLQ after fixing the issue.
Next Steps
- Payment Orders - Understanding payment orders
- Integration Guides - Framework-specific examples
How is this guide?
Last updated
Idempotency
Use StableOps idempotency keys and merchant order IDs to retry payment creation, handle conflicting requests, and deduplicate Webhooks before fulfillment.
App notifications
Connect StableOps payment notifications to Telegram and Discord, choose destinations, inspect delivery outcomes, and recover from failed status updates.