Skip to main content
TapRails sends signed HTTP POST requests to your configured webhook URL whenever key events occur — payment confirmations, pool deposits, session key changes, and more. All webhook deliveries are tracked, and failed deliveries are automatically retried with exponential backoff for up to 24 hours. You can also view the full delivery history and trigger manual retries from the dashboard or the API.
Webhooks are optional but strongly recommended for production integrations. Without them, you must poll the API to track payment and pool state changes.

Setup

Configure your endpoint in the Dashboard

  1. Navigate to Dashboard → Settings → Webhooks
  2. Enter your publicly accessible HTTPS endpoint URL
  3. Copy the generated Webhook Secret — you’ll need it to verify signatures
Your endpoint must use HTTPS and be reachable from the internet. Local development servers are not reachable by default — use a tunnel tool like ngrok or Cloudflare Tunnel during testing.

Required response behaviour

TapRails considers a webhook delivery successful when your endpoint returns any 2xx HTTP status code within 10 seconds. Any other outcome — a non-2xx response, a timeout, or a network error — marks the delivery as FAILED and schedules an automatic retry.
Respond with 200 OK immediately, then process the event asynchronously. Never perform long-running work (DB writes, external calls) before sending your response — you risk hitting the 10-second timeout.

Delivery & Retry Behaviour

TapRails uses a 5-attempt exponential backoff system to ensure your endpoint receives every event, even if your server is temporarily down.

Backoff Schedule

AttemptDelay after previous failure
1st (initial send)Immediate
2nd retry5 minutes
3rd retry15 minutes
4th retry45 minutes
5th retry2 hours
6th retry6 hours
After 5 failed retries (6 total attempts), the webhook log is marked permanently failed and no further automatic retries are attempted. You can still trigger a manual retry from the dashboard or API at any time.
The total retry window is approximately 9 hours from the first failed attempt. Ensure your webhook endpoint is recoverable within this window for guaranteed delivery.

Delivery Log

Every delivery attempt — successful or not — is recorded. Each log entry includes:
FieldDescription
statusSUCCESS, FAILED, or PENDING
attemptsHow many delivery attempts have been made
last_attempt_atTimestamp of the most recent attempt
next_retry_atScheduled time for the next automatic retry
response_statusHTTP status code returned by your endpoint
response_bodyFirst 1,000 characters of your endpoint’s response
error_messageNetwork error or timeout message (if applicable)

Viewing & Managing Deliveries

From the Dashboard:
  • Navigate to Settings → Webhooks → Delivery Logs to browse all attempts.
  • Filter by status (e.g. FAILED) or event type.
  • Click Retry on any log entry to dispatch it immediately, regardless of the scheduled retry time.
From the API:
# List delivery logs
GET /api/v1/dashboard/webhooks/logs?status=FAILED&limit=50

# Manually retry a specific delivery
POST /api/v1/dashboard/webhooks/{log_id}/retry

Automatic Retry Cron Job

Retries are dispatched by a background cron job that runs every 5–15 minutes. It picks up queued retries, processes up to 20 per run, and returns a summary:
{
  "message": "Webhook retry job complete",
  "total_processed": 3,
  "successful": 2,
  "failed": 1
}
The cron endpoint itself is protected with a CRON_SECRET and is typically called by a scheduler such as Vercel Cron, Railway Cron, or a GitHub Actions schedule:
GET /api/v1/cron/webhooks/retry
Authorization: Bearer YOUR_CRON_SECRET
If you are self-hosting TapRails, you must configure this cron job for automatic retries to work. Without it, only manual retries via the dashboard or API will function.

Verifying Webhook Signatures

Every webhook request is signed using HMAC-SHA256 with your Webhook Secret. Always verify the signature before processing the payload.

Headers sent with every request

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 hex digest of the raw JSON body
X-Webhook-TimestampISO 8601 timestamp of when the event was dispatched
X-Webhook-EventThe event type string (e.g. payment.confirmed)
User-AgentTapRail-Webhook/1.0

Verification examples

import crypto from 'crypto';

function verifyWebhookSignature(
  rawBody: string,
  receivedSignature: string,
  secret: string
): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  const a = Buffer.from(expectedSignature, 'hex');
  const b = Buffer.from(receivedSignature, 'hex');

  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Usage in an Express route
app.post('/webhooks/taprails', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  const rawBody = req.body.toString('utf-8'); // raw bytes → string

  if (!verifyWebhookSignature(rawBody, signature, process.env.TAPRAILS_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(rawBody);
  // Handle event...

  res.status(200).json({ received: true });
});
Always read the raw request body before parsing JSON. Many frameworks (e.g. Express with json() middleware) will parse and re-serialize the body, which can change byte order or whitespace and break signature verification.

Payload Structure

All webhook payloads share a common envelope:
{
  "event": "payment.confirmed",
  "timestamp": "2026-04-03T14:22:00.000Z",
  "data": {
    // event-specific fields
  }
}
FieldTypeDescription
eventstringEvent type identifier
timestampstringISO 8601 UTC timestamp
dataobjectEvent-specific payload (see below)

Event Reference

Payment Events

payment.created

Fired when a new payment request is created by a merchant device.
{
  "event": "payment.created",
  "timestamp": "2026-04-03T14:20:00.000Z",
  "data": {
    "payment_id": "pay_abc123",
    "merchant_id": "mch_xyz789",
    "merchant_wallet": "0xMerchantAddress...",
    "amount": "25.00",
    "status": "PENDING",
    "expires_at": "2026-04-03T14:25:00.000Z",
    "created_at": "2026-04-03T14:20:00.000Z"
  }
}

payment.processing

Fired when the customer’s device has submitted the transaction to the blockchain. The payment is on-chain but not yet confirmed.
{
  "event": "payment.processing",
  "timestamp": "2026-04-03T14:22:00.000Z",
  "data": {
    "payment_id": "pay_abc123",
    "merchant_id": "mch_xyz789",
    "merchant_wallet": "0xMerchantAddress...",
    "customer_wallet": "0xCustomerAddress...",
    "amount": "25.00",
    "tx_hash": "0xTransactionHash...",
    "status": "PROCESSING"
  }
}

payment.confirmed

Fired when the payment transaction is confirmed on-chain. This is the definitive success signal — use this to fulfil orders.
{
  "event": "payment.confirmed",
  "timestamp": "2026-04-03T14:22:30.000Z",
  "data": {
    "payment_id": "pay_abc123",
    "merchant_id": "mch_xyz789",
    "merchant_wallet": "0xMerchantAddress...",
    "customer_wallet": "0xCustomerAddress...",
    "amount": "25.00",
    "tx_hash": "0xTransactionHash...",
    "block_explorer_url": "https://basescan.org/tx/0xTransactionHash...",
    "status": "CONFIRMED",
    "confirmed_at": "2026-04-03T14:22:30.000Z"
  }
}

payment.failed

Fired when a payment transaction reverts or fails on-chain.
{
  "event": "payment.failed",
  "timestamp": "2026-04-03T14:22:05.000Z",
  "data": {
    "payment_id": "pay_abc123",
    "merchant_id": "mch_xyz789",
    "amount": "25.00",
    "tx_hash": "0xTransactionHash...",
    "status": "FAILED",
    "error_message": "insufficient funds"
  }
}

payment.expired

Fired when a payment request passes its expiry time without being completed.
{
  "event": "payment.expired",
  "timestamp": "2026-04-03T14:25:01.000Z",
  "data": {
    "payment_id": "pay_abc123",
    "merchant_id": "mch_xyz789",
    "amount": "25.00",
    "status": "EXPIRED",
    "expired_at": "2026-04-03T14:25:00.000Z"
  }
}

Pool Events

Pool events notify you about changes to your company’s USDC liquidity pool, which funds merchant payouts.

pool.deposit_received

Fired when USDC is deposited into your treasury wallet. Pool balance is automatically synced.
{
  "event": "pool.deposit_received",
  "timestamp": "2026-04-03T12:00:00.000Z",
  "data": {
    "company_id": "co_abc123",
    "amount": "500.00",
    "tx_hash": "0xDepositTxHash...",
    "balance_before": "1000.00",
    "balance_after": "1500.00",
    "currency": "USDC",
    "timestamp": "2026-04-03T12:00:00.000Z"
  }
}

pool.withdrawal_completed

Fired when USDC is withdrawn from your treasury wallet.
{
  "event": "pool.withdrawal_completed",
  "timestamp": "2026-04-03T13:00:00.000Z",
  "data": {
    "withdrawal_id": "wdrl_xyz456",
    "company_id": "co_abc123",
    "amount": "200.00",
    "to_address": "0xDestinationAddress...",
    "tx_hash": "0xWithdrawalTxHash...",
    "balance_before": "1500.00",
    "balance_after": "1300.00",
    "currency": "USDC",
    "timestamp": "2026-04-03T13:00:00.000Z"
  }
}

pool.low_balance

Fired when your pool balance drops below the configured low-balance threshold. Use this to trigger an automatic top-up or alert your operations team.
{
  "event": "pool.low_balance",
  "timestamp": "2026-04-03T11:00:00.000Z",
  "data": {
    "company_id": "co_abc123",
    "balance": "45.00",
    "threshold": "100.00",
    "currency": "USDC",
    "timestamp": "2026-04-03T11:00:00.000Z"
  }
}

Session Key Events

Session key events track the lifecycle of customer session keys used for gas-free USDC payments.

session_key.registered

Fired when a customer successfully registers a session key on their device.
{
  "event": "session_key.registered",
  "timestamp": "2026-04-03T09:00:00.000Z",
  "data": {
    "session_key_id": "sk_abc123",
    "user_id": "usr_xyz789",
    "wallet_address": "0xCustomerAddress...",
    "runner_public_key": "0xRunnerPublicKey...",
    "device_id": "dev_qrs456",
    "daily_spend_limit": "500.00",
    "expires_at": "2026-04-10T09:00:00.000Z",
    "registered_at": "2026-04-03T09:00:00.000Z"
  }
}

session_key.revoked

Fired when a session key is explicitly revoked (e.g. user logs out or de-authorises a device).
{
  "event": "session_key.revoked",
  "timestamp": "2026-04-03T17:30:00.000Z",
  "data": {
    "session_key_id": "sk_abc123",
    "user_id": "usr_xyz789",
    "wallet_address": "0xCustomerAddress...",
    "revoked_at": "2026-04-03T17:30:00.000Z"
  }
}

session_key.limit_exceeded

Fired when a payment attempt is blocked because it would exceed the session key’s daily spend limit.
{
  "event": "session_key.limit_exceeded",
  "timestamp": "2026-04-03T15:00:00.000Z",
  "data": {
    "session_key_id": "sk_abc123",
    "user_id": "usr_xyz789",
    "wallet_address": "0xCustomerAddress...",
    "payment_id": "pay_def456",
    "attempted_amount": "150.00",
    "daily_limit": "500.00",
    "spent_today": "400.00"
  }
}

Device Events

device.registered

Fired when a merchant device completes registration with the TapRails SDK.
{
  "event": "device.registered",
  "timestamp": "2026-04-03T08:00:00.000Z",
  "data": {
    "device_id": "dev_qrs456",
    "merchant_id": "mch_xyz789",
    "device_uuid": "550e8400-e29b-41d4-a716-446655440000",
    "public_key": "0xDevicePublicKey...",
    "metadata": {
      "model": "Samsung Galaxy S24",
      "platform": "android",
      "os_version": "14",
      "app_version": "1.2.0"
    },
    "registered_at": "2026-04-03T08:00:00.000Z"
  }
}

Complete Event List

EventTrigger
payment.createdA merchant creates a new payment request
payment.processingCustomer submits payment transaction to chain
payment.confirmedPayment transaction confirmed on-chain ✅
payment.failedPayment transaction reverted or failed ❌
payment.expiredPayment request expired before completion
pool.deposit_receivedUSDC deposited into your treasury wallet
pool.withdrawal_completedUSDC withdrawn from your treasury wallet
pool.low_balancePool balance fell below configured threshold
session_key.registeredCustomer registered a new session key
session_key.revokedSession key was revoked
session_key.limit_exceededPayment blocked by daily spend limit
device.registeredMerchant device completed SDK registration

Testing Webhooks

Local development with ngrok

Your endpoint must be publicly reachable over HTTPS. For local development, use a tunnel:
# Expose your local server via ngrok
ngrok http 3000

# Set the generated URL in the Dashboard:
# https://abc123.ngrok.io/webhooks/taprails

Simulating a payment (Test Mode)

In test mode, you can trigger a full end-to-end payment cycle from the dashboard to validate your webhook handler:
  1. Navigate to Dashboard → Payments → Simulate
  2. Select a merchant and amount
  3. TapRails fires payment.createdpayment.processingpayment.confirmed in sequence

Debugging failed deliveries

In the Dashboard: Navigate to Settings → Webhooks → Delivery Logs. Each entry shows status, HTTP response code, response body (first 1,000 chars), and the next scheduled retry time. Click Retry to re-dispatch immediately. Via API:
# Get all failed deliveries
GET /api/v1/dashboard/webhooks/logs?status=FAILED

# Manually retry a specific log entry
POST /api/v1/dashboard/webhooks/{log_id}/retry
Example log entry response:
{
  "logs": [
    {
      "id": "whl_abc123",
      "event_type": "payment.confirmed",
      "status": "FAILED",
      "attempts": 2,
      "last_attempt_at": "2026-04-04T14:30:00.000Z",
      "next_retry_at": "2026-04-04T15:15:00.000Z",
      "response_status": 503,
      "response_body": "Service Unavailable",
      "error_message": null
    }
  ]
}

Rotating your webhook secret during testing

# Roll the webhook secret via API
POST /api/v1/dashboard/webhooks/config
Content-Type: application/json

{ "rollSecret": true }
The response will include the new secret in plain text — it is never shown again after this point.

Security Best Practices

Never process a webhook payload without first verifying the X-Webhook-Signature header using your webhook secret. Any request that fails verification should be rejected with a 401.
TapRails aborts delivery if your endpoint doesn’t respond within 10 seconds. Respond with 200 OK right away and push the event to an internal queue (e.g. Redis, BullMQ, SQS) for background processing. If your server is down, the automatic retry system will re-attempt delivery up to 5 more times over approximately 9 hours.
The same event will be delivered more than once if retries are triggered. Use the payment_id, tx_hash, or withdrawal_id in the payload as an idempotency key — check whether you’ve already processed an event before acting on it. A simple SET payment_id = processed in your database is sufficient.
Treat your webhook secret like a password. Rotate it in the Dashboard periodically and update your server environment variable accordingly. Secrets are never exposed after initial generation.
Don’t rely solely on the X-Webhook-Event header — always read the event field from the parsed JSON body and branch your logic based on it.