> ## Documentation Index
> Fetch the complete documentation index at: https://docs.taprails.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time event notifications for payments, pool activity, and session key lifecycle events — with automatic retries and a full delivery log.

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.

<Info>
  Webhooks are optional but strongly recommended for production integrations. Without them, you must poll the API to track payment and pool state changes.
</Info>

***

## 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

<Warning>
  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](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) during testing.
</Warning>

### 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.

<Tip>
  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.
</Tip>

***

## 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

| Attempt            | Delay after previous failure |
| :----------------- | :--------------------------- |
| 1st (initial send) | Immediate                    |
| 2nd retry          | 5 minutes                    |
| 3rd retry          | 15 minutes                   |
| 4th retry          | 45 minutes                   |
| 5th retry          | 2 hours                      |
| 6th retry          | 6 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.

<Note>
  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.
</Note>

### Delivery Log

Every delivery attempt — successful or not — is recorded. Each log entry includes:

| Field             | Description                                        |
| :---------------- | :------------------------------------------------- |
| `status`          | `SUCCESS`, `FAILED`, or `PENDING`                  |
| `attempts`        | How many delivery attempts have been made          |
| `last_attempt_at` | Timestamp of the most recent attempt               |
| `next_retry_at`   | Scheduled time for the next automatic retry        |
| `response_status` | HTTP status code returned by your endpoint         |
| `response_body`   | First 1,000 characters of your endpoint's response |
| `error_message`   | Network 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:**

```bash theme={null}
# 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:

```json theme={null}
{
  "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:

```bash theme={null}
GET /api/v1/cron/webhooks/retry
Authorization: Bearer YOUR_CRON_SECRET
```

<Warning>
  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.
</Warning>

***

## 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

| Header                | Description                                         |
| --------------------- | --------------------------------------------------- |
| `X-Webhook-Signature` | HMAC-SHA256 hex digest of the raw JSON body         |
| `X-Webhook-Timestamp` | ISO 8601 timestamp of when the event was dispatched |
| `X-Webhook-Event`     | The event type string (e.g. `payment.confirmed`)    |
| `User-Agent`          | `TapRail-Webhook/1.0`                               |

### Verification examples

<CodeGroup>
  ```typescript Node.js / TypeScript theme={null}
  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 });
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import json

  def verify_webhook_signature(raw_body: str, received_signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode('utf-8'),
          raw_body.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(expected, received_signature)

  # Usage in a Flask route
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.route('/webhooks/taprails', methods=['POST'])
  def handle_webhook():
      raw_body = request.get_data(as_text=True)
      signature = request.headers.get('X-Webhook-Signature', '')

      if not verify_webhook_signature(raw_body, signature, TAPRAILS_WEBHOOK_SECRET):
          abort(401)

      event = json.loads(raw_body)
      handle_event(event)

      return {'received': True}, 200
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "io"
      "net/http"
  )

  func verifySignature(body []byte, signature, secret string) bool {
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write(body)
      expected := hex.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(expected), []byte(signature))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      sig := r.Header.Get("X-Webhook-Signature")

      if !verifySignature(body, sig, webhookSecret) {
          http.Error(w, "Invalid signature", http.StatusUnauthorized)
          return
      }

      var event map[string]interface{}
      json.Unmarshal(body, &event)
      // Handle event...

      w.WriteHeader(http.StatusOK)
      json.NewEncoder(w).Encode(map[string]bool{"received": true})
  }
  ```
</CodeGroup>

<Warning>
  **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.
</Warning>

***

## Payload Structure

All webhook payloads share a common envelope:

```json theme={null}
{
  "event": "payment.confirmed",
  "timestamp": "2026-04-03T14:22:00.000Z",
  "data": {
    // event-specific fields
  }
}
```

| Field       | Type     | Description                        |
| ----------- | -------- | ---------------------------------- |
| `event`     | `string` | Event type identifier              |
| `timestamp` | `string` | ISO 8601 UTC timestamp             |
| `data`      | `object` | Event-specific payload (see below) |

***

## Event Reference

### Payment Events

#### `payment.created`

Fired when a new payment request is created by a merchant device.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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).

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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

| Event                        | Trigger                                       |
| ---------------------------- | --------------------------------------------- |
| `payment.created`            | A merchant creates a new payment request      |
| `payment.processing`         | Customer submits payment transaction to chain |
| `payment.confirmed`          | Payment transaction confirmed on-chain ✅      |
| `payment.failed`             | Payment transaction reverted or failed ❌      |
| `payment.expired`            | Payment request expired before completion     |
| `pool.deposit_received`      | USDC deposited into your treasury wallet      |
| `pool.withdrawal_completed`  | USDC withdrawn from your treasury wallet      |
| `pool.low_balance`           | Pool balance fell below configured threshold  |
| `session_key.registered`     | Customer registered a new session key         |
| `session_key.revoked`        | Session key was revoked                       |
| `session_key.limit_exceeded` | Payment blocked by daily spend limit          |
| `device.registered`          | Merchant device completed SDK registration    |

***

## Testing Webhooks

### Local development with ngrok

Your endpoint must be publicly reachable over HTTPS. For local development, use a tunnel:

```bash theme={null}
# 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.created` → `payment.processing` → `payment.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:**

```bash theme={null}
# 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:**

```json theme={null}
{
  "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

```bash theme={null}
# 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

<AccordionGroup>
  <Accordion title="Always verify the signature">
    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`.
  </Accordion>

  <Accordion title="Respond immediately — process asynchronously">
    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.
  </Accordion>

  <Accordion title="Make your handlers idempotent">
    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.
  </Accordion>

  <Accordion title="Rotate your webhook secret periodically">
    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.
  </Accordion>

  <Accordion title="Validate the event type before acting">
    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.
  </Accordion>
</AccordionGroup>
