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

# Webhook testing

> Test webhook integrations with signed test deliveries, ngrok, webhook.site, and CLI tools

<blockquote className="agent-llms-directive">
  For the complete documentation index, see <a href="/llms.txt">llms.txt</a>.
</blockquote>

Test your webhook integration before deploying to production. Use Xquik's signed test delivery, local HTTPS tunnels, payload inspectors, and delivery logs to verify handlers before real monitor events arrive.

## Test with Xquik first

Use the [Test Webhook](/api-reference/webhooks/test) endpoint after you create a webhook. Xquik sends a real `webhook.test` delivery to the configured URL with the same `X-Xquik-Signature`, `X-Xquik-Timestamp`, and `X-Xquik-Nonce` headers used for production monitor events.

`webhook.test` payloads contain `eventType`, `data.message`, and `timestamp`;
they omit `deliveryId` and `streamEventId`, so use them for reachability and
signature checks, not production de-dupe.

```bash theme={null}
curl -X POST https://xquik.com/api/v1/webhooks/15/test \
  -H "x-api-key: xq_YOUR_KEY_HERE" | jq
```

**Response when your endpoint accepts the delivery:**

```json theme={null}
{
  "success": true,
  "statusCode": 200
}
```

**Response when your endpoint rejects the delivery:**

```json theme={null}
{
  "success": false,
  "statusCode": 500,
  "error": "HTTP 500"
}
```

Check your server logs for the `webhook.test` payload, verify the HMAC before processing it, and return a 2xx response only after your handler accepts the event.

## End-to-end handoff check

Use a signed test delivery to prove the receiver can verify requests, then use
production monitor deliveries to prove idempotency and storage behavior.

```json theme={null}
{
  "workflow": "signed_webhook_test_handoff",
  "webhook": {
    "webhook_id": "15",
    "url": "https://example.com/xquik/webhook",
    "event_types": ["tweet.new", "tweet.reply"],
    "secret_storage": "store_once"
  },
  "test_delivery": {
    "endpoint": "/api/v1/webhooks/15/test",
    "event_type": "webhook.test",
    "has_delivery_id": false,
    "has_stream_event_id": false,
    "signature_headers": [
      "X-Xquik-Signature",
      "X-Xquik-Timestamp",
      "X-Xquik-Nonce"
    ],
    "receiver_status": 200,
    "success": true
  },
  "production_delivery": {
    "event_type": "tweet.new",
    "delivery_id": "502",
    "stream_event_id": "9002",
    "dedupe_keys": ["deliveryId", "streamEventId"],
    "return_status": "2xx_before_slow_work"
  },
  "delivery_triage": {
    "endpoint": "/api/v1/webhooks/15/deliveries",
    "status": "failed",
    "attempts": 3,
    "last_status_code": 500,
    "last_error": "Internal Server Error"
  },
  "event_context": {
    "endpoint": "GET /api/v1/events/{id}",
    "id_source": "streamEventId",
    "store": ["monitorId", "monitorType", "type", "occurredAt", "data"]
  },
  "handoff_state": "verified_signature_then_join_event_context"
}
```

<CardGroup cols={2}>
  <Card title="Signature checkpoint" icon="shield-check">
    Store the webhook `secret` once. Verify the raw body with
    `X-Xquik-Signature`, `X-Xquik-Timestamp`, and `X-Xquik-Nonce` before
    parsing or queueing the event.
  </Card>

  <Card title="Test payload checkpoint" icon="file-json">
    Treat `webhook.test` as a signed reachability check. It includes
    `eventType`, `data.message`, and `timestamp`, and omits `deliveryId` and
    `streamEventId`.
  </Card>

  <Card title="Production de-dupe" icon="copy-check">
    Use `deliveryId` for receiver retry de-dupe. Use `streamEventId` when one
    monitor event should process once across endpoint changes.
  </Card>

  <Card title="Delivery triage" icon="activity">
    Query `GET /api/v1/webhooks/{id}/deliveries` for `status`, `attempts`,
    `lastStatusCode`, `lastError`, `createdAt`, and `deliveredAt` before
    paging or replaying work.
  </Card>

  <Card title="Event join" icon="link">
    Use delivery `streamEventId` as the `{id}` for
    [Get Event](/api-reference/events/get). Store the event `monitorId`,
    `monitorType`, `type`, `occurredAt`, and `data` with the receiver incident.
  </Card>
</CardGroup>

### Receiver replay row

Store signed test deliveries separately from production delivery rows. A
`webhook.test` row proves reachability and HMAC verification only. Production
rows carry the IDs your receiver needs for retry de-dupe, event de-dupe, and
event detail joins.

```json theme={null}
{
  "record_type": "webhook_receiver_replay_check",
  "webhook_id": "15",
  "test_payload_has_ids": false,
  "production_delivery_id": "502",
  "production_stream_event_id": "9002",
  "signature_verified": true,
  "nonce_cache_key": "webhook:15:nonce_hash",
  "event_join": "GET /api/v1/events/9002",
  "delivery_log": "GET /api/v1/webhooks/15/deliveries",
  "shared_storage_excludes": [
    "endpoint_signing_values",
    "raw_request_body",
    "raw_signature",
    "full_headers"
  ]
}
```

Use this row after verification succeeds and before slow downstream work. Keep
the raw body available for the HMAC check, then store only the de-dupe IDs,
join route, verification state, and sanitized delivery context in shared
systems.

## Local testing with ngrok

[ngrok](https://ngrok.com) creates a public HTTPS tunnel to your local server, letting Xquik deliver webhooks to your development machine.

<Steps>
  <Step title="Install ngrok">
    ```bash theme={null}
    # macOS
    brew install ngrok

    # Linux
    curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok-v3-stable-linux-amd64.tgz \
      | tar -xz -C /usr/local/bin

    # Authenticate (free account required)
    ngrok config add-authtoken YOUR_NGROK_TOKEN
    ```
  </Step>

  <Step title="Start your local server">
    Run your webhook handler on a local port (e.g. `:3000`):

    ```bash theme={null}
    node server.js
    # or: python app.py
    # or: go run main.go
    ```
  </Step>

  <Step title="Start the ngrok tunnel">
    ```bash theme={null}
    ngrok http 3000
    ```

    ngrok outputs a public HTTPS URL:

    ```text theme={null}
    Forwarding  https://a1b2c3d4.ngrok-free.app → http://localhost:3000
    ```

    Copy the `https://` URL.
  </Step>

  <Step title="Create a webhook with the ngrok URL">
    ```bash theme={null}
    curl -X POST https://xquik.com/api/v1/webhooks \
      -H "x-api-key: xq_YOUR_KEY_HERE" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://a1b2c3d4.ngrok-free.app/webhook",
        "eventTypes": ["tweet.new", "tweet.reply"]
      }' | jq
    ```

    Save the `secret` from the response for signature verification.
  </Step>

  <Step title="Send a signed test delivery">
    Trigger a signed `webhook.test` request through the tunnel before waiting for real tweet events:

    ```bash theme={null}
    curl -X POST https://xquik.com/api/v1/webhooks/15/test \
      -H "x-api-key: xq_YOUR_KEY_HERE" | jq
    ```

    Inspect your app logs and the ngrok web inspector at `http://localhost:4040`.
  </Step>

  <Step title="Trigger events">
    When a monitored account posts a tweet, Xquik delivers the event through ngrok to your local server. Check your server logs and the ngrok web inspector at `http://localhost:4040`.
  </Step>
</Steps>

<Warning>
  ngrok URLs change every time you restart the tunnel (free plan). Update your webhook URL after each restart, or use a paid ngrok plan for stable subdomains.
</Warning>

## Testing with webhook.site

Use [webhook.site](https://webhook.site) to inspect webhook payloads without running a local server.

<Steps>
  <Step title="Get a webhook.site URL">
    Visit [webhook.site](https://webhook.site). A unique HTTPS URL is generated automatically:

    ```text theme={null}
    https://webhook.site/a1b2c3d4-e5f6-7890-abcd-ef1234567890
    ```
  </Step>

  <Step title="Create a webhook with the webhook.site URL">
    ```bash theme={null}
    curl -X POST https://xquik.com/api/v1/webhooks \
      -H "x-api-key: xq_YOUR_KEY_HERE" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://webhook.site/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "eventTypes": ["tweet.new"]
      }' | jq
    ```
  </Step>

  <Step title="View incoming payloads">
    When events arrive, they appear in the webhook.site dashboard in real time. Inspect headers (`X-Xquik-Signature`, `Content-Type`) and the JSON body to verify the payload format matches your expectations.
  </Step>
</Steps>

<Note>
  webhook.site is useful for inspecting payload structure. For testing signature verification and handler logic, use [ngrok with a local server](#local-testing-with-ngrok) instead.
</Note>

## Sending test payloads

Simulate a webhook delivery to your local handler without calling Xquik. This is useful for unit tests or offline debugging. For end-to-end verification of the configured webhook URL, prefer `POST /webhooks/{id}/test`.

```bash theme={null}
# Generate a test signature
SECRET="your_webhook_secret_here"
PAYLOAD=$(cat <<'JSON'
{"eventType":"tweet.new","schemaVersion":1,"deliveryId":"502","streamEventId":"9002","occurredAt":"2026-02-24T14:22:00.000Z","username":"elonmusk","data":{"id":"1893456789012345678","text":"The future is now.","author":{"id":"44196397","userName":"elonmusk","name":"Elon Musk"},"isRetweet":false,"isReply":false,"isQuote":false,"createdAt":"2026-02-24T14:22:00.000Z"}}
JSON
)
TIMESTAMP=$(date +%s)000
NONCE=$(openssl rand -hex 16)

SIGNATURE="sha256=$(printf '%s.%s.%s' "$TIMESTAMP" "$NONCE" "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)"

# Send to your local server
curl -X POST http://localhost:3000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Xquik-Timestamp: $TIMESTAMP" \
  -H "X-Xquik-Nonce: $NONCE" \
  -H "X-Xquik-Signature: $SIGNATURE" \
  -d "$PAYLOAD"
```

**Test payload structure:**

```json theme={null}
{
  "eventType": "tweet.new",
  "schemaVersion": 1,
  "deliveryId": "502",
  "streamEventId": "9002",
  "occurredAt": "2026-02-24T14:22:00.000Z",
  "username": "elonmusk",
  "data": {
    "id": "1893456789012345678",
    "text": "The future is now.",
    "author": {
      "id": "44196397",
      "userName": "elonmusk",
      "name": "Elon Musk"
    },
    "isRetweet": false,
    "isReply": false,
    "isQuote": false,
    "createdAt": "2026-02-24T14:22:00.000Z"
  }
}
```

Include `deliveryId` and `streamEventId` in offline fixtures so receiver idempotency tests match production deliveries. Test all event types by changing the `eventType` field: `tweet.new`, `tweet.reply`, `tweet.quote`, `tweet.retweet`.

## Debugging delivery failures

### Check delivery status

Query the [deliveries endpoint](/api-reference/webhooks/deliveries) to see delivery attempts and error details:

```bash theme={null}
curl https://xquik.com/api/v1/webhooks/15/deliveries \
  -H "x-api-key: xq_YOUR_KEY_HERE" | jq
```

**Response:**

```json theme={null}
{
  "deliveries": [
    {
      "id": "502",
      "streamEventId": "9002",
      "status": "failed",
      "attempts": 3,
      "lastStatusCode": 500,
      "lastError": "Internal Server Error",
      "createdAt": "2026-02-24T14:25:00.000Z"
    }
  ]
}
```

Join `streamEventId` to [Get Event](/api-reference/events/get) when a receiver
owner needs the original monitor event behind a failed or exhausted delivery.

```bash theme={null}
curl https://xquik.com/api/v1/events/9002 \
  -H "x-api-key: xq_YOUR_KEY_HERE" | jq
```

Store the event `monitorId`, `monitorType`, `type`, `occurredAt`, and `data`
with the delivery `id`, `status`, `attempts`, `lastStatusCode`, and
`lastError` before paging support, queue, or receiver owners.

### Reconcile missed deliveries

Use delivery rows for receiver attempts and event pages for stored monitor
history. The delivery `streamEventId` is the event `id`, so a failed receiver
can rebuild its downstream queue from stored events after the handler is fixed.

```json theme={null}
{
  "record_type": "webhook_delivery_reconciliation",
  "delivery_endpoint": "GET /api/v1/webhooks/15/deliveries",
  "event_backfill_endpoint": "GET /api/v1/events?limit=100&after={nextCursor}",
  "join_key": "delivery.streamEventId == event.id",
  "store": [
    "deliveryId",
    "streamEventId",
    "status",
    "attempts",
    "eventId",
    "nextCursor"
  ],
  "next_action": "rebuild_receiver_queue_after_handler_fix"
}
```

Store `nextCursor` after each event page. Continue with
`GET /api/v1/events?limit=100&after={nextCursor}` until `hasMore` is `false`,
then compare event IDs with delivery `streamEventId` values before replaying
your own downstream work.

### Delivery statuses

<CardGroup cols={2}>
  <Card title="pending" icon="clock">
    Queued for the next delivery attempt. Check recent deploys, tunnel uptime,
    and receiver availability before forcing a new test.
  </Card>

  <Card title="delivered" icon="circle-check">
    Your endpoint returned `2xx`. Confirm your handler verified the signature
    and stored the event before it returned success.
  </Card>

  <Card title="failed" icon="triangle-alert">
    The latest attempt failed and is retrying with backoff. Inspect
    `lastStatusCode`, `lastError`, and your receiver logs.
  </Card>

  <Card title="exhausted" icon="circle-x">
    All retry attempts are used, or the receiver returned `410 Gone`. Fix the
    endpoint, then send a new signed test delivery.
  </Card>
</CardGroup>

### Common failure reasons

<AccordionGroup>
  <Accordion title="Slow response">
    We recommend responding within 10 seconds. If your handler is slow, return `200` immediately and process the event asynchronously using a background job queue.
  </Accordion>

  <Accordion title="Non-2xx response">
    Any response outside the 200-299 range counts as a failure. Check your server logs for unhandled exceptions or validation errors in your handler. Common culprits: missing middleware (e.g. `express.raw()`), JSON parse errors, or database connection failures.
  </Accordion>

  <Accordion title="DNS resolution failure">
    The webhook URL hostname could not be resolved. Verify your domain DNS records are correct. If using ngrok, confirm the tunnel is still running.
  </Accordion>

  <Accordion title="TLS/SSL errors">
    Webhook URLs must use HTTPS with a valid certificate. Self-signed certificates are rejected. Use a trusted CA (Let's Encrypt, Cloudflare) or ngrok for local development.
  </Accordion>

  <Accordion title="Connection refused">
    The target server is not accepting connections. Verify your server is running and listening on the correct port. Check firewall rules if running on a cloud provider.
  </Accordion>

  <Accordion title="Signature verification failing">
    Compute the HMAC over the **raw request body bytes**, not a re-serialized JSON object. Re-serialization can alter whitespace or key ordering. Use `express.raw()` in Node.js, `request.get_data()` in Flask, or `io.ReadAll(r.Body)` in Go.
  </Accordion>
</AccordionGroup>

<CardGroup cols={3}>
  <Card title="Webhooks Overview" icon="webhook" href="/webhooks/overview">
    How webhooks work, delivery format, and retry policy.
  </Card>

  <Card title="Signature Verification" icon="shield-check" href="/webhooks/verification">
    HMAC-SHA256 verification in Node.js, Python, and Go.
  </Card>

  <Card title="Deliveries API" icon="truck" href="/api-reference/webhooks/deliveries">
    Query delivery attempts and statuses.
  </Card>
</CardGroup>
