# Advanced Webhooks

Advanced webhook configuration: retries, signatures, event filtering and delivery history.

All endpoints below are admin API routes: authenticate with `Authorization: Bearer <JWT>` (or an API key in `x-api-key`). A merchant-scoped caller can only see and manage its own merchant's endpoints.

---

## Webhook Retry Configuration

Retries are configured per endpoint with two fields, set on create or update:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `maxRetries` | integer | `3` | Total delivery attempts, including the first one |
| `retryDelaySeconds` | integer | `60` | Base delay; doubles after each failed attempt |

### Endpoint

```http
PATCH /api/v1/webhooks/endpoints/:id
```

```bash
curl -X PATCH https://uat.auxcore.net/api/v1/webhooks/endpoints/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "maxRetries": 5,
    "retryDelaySeconds": 120
  }'
```

Other updatable fields: `url`, `description`, `events`, `isActive`.

### How retries work

- A delivery succeeds only on an HTTP `2xx` response within **30 seconds**. Anything else (non-2xx, timeout, connection error) is a failure.
- After failed attempt *n*, if *n* < `maxRetries`, the delivery is set to `retrying` with the next attempt at `retryDelaySeconds × 2^(n-1)` (defaults: 60 s, then 120 s).
- A background sweep picks up due retries about once a minute, up to 100 per sweep, so actual retry times can lag by up to a minute.
- When attempts are exhausted the delivery stays `failed`. A delivery for an endpoint that has since been deactivated or deleted is marked `failed` without being sent.

---

## Webhook Signatures

Every delivery is signed with the endpoint's secret (HMAC-SHA256).

### Headers sent

| Header | Value |
|--------|-------|
| `X-Webhook-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `X-Webhook-Event-Type` | Event name, e.g. `transaction.approved` |
| `X-Webhook-Event-ID` | Event id (same for every retry of the event) |
| `X-Webhook-Delivery-ID` | Delivery id |
| `User-Agent` | `AuxVault-Webhooks/1.0` |
| `Content-Type` | `application/json` |

### Verifying

The signed string is `<t>.<raw request body>`. Compute HMAC-SHA256 over it with your secret and compare (constant-time) to `v1`. Reject timestamps more than 5 minutes from your clock to prevent replays. Always verify against the **raw** body bytes, not a re-serialised parsed object.

```javascript
const crypto = require('crypto');

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex');
  return crypto.timingSafeEqual(Buffer.from(parts.v1, 'hex'), Buffer.from(expected, 'hex'));
}
```

### Payload envelope

```json
{
  "id": "0b7f5c2e-3d1a-4f6b-9e8c-1a2b3c4d5e6f",
  "type": "transaction.approved",
  "created": "2026-01-28T15:00:00.000Z",
  "data": { "...": "event-specific fields" },
  "metadata": {
    "tenantId": "your-tenant-id",
    "merchantId": "3f2a9c1e-7b4d-4e8a-9f10-2c5d6e7f8a90"
  }
}
```

### Secret handling

The signing secret is returned **only** in the create response and by regenerate-secret. List/get/update responses mask it (`hasSecret`, `secretHint` with the last 4 characters).

```http
POST /api/v1/webhooks/endpoints/:id/regenerate-secret
```

**Response:** `{ "success": true, "data": { "secret": "…" } }` — the old secret stops working immediately.

---

## Webhook Filtering

Subscribe to specific events by listing them in `events`. Names must match exactly — wildcards (`transaction.*`) and payload filters (e.g. amount thresholds) are not supported.

### Endpoint

```http
POST /api/v1/webhooks/endpoints
```

```bash
curl -X POST https://uat.auxcore.net/api/v1/webhooks/endpoints \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhook",
    "description": "Order system",
    "events": ["transaction.approved", "transaction.declined", "transaction.refunded"],
    "maxRetries": 3,
    "retryDelaySeconds": 60
  }'
```

| Field | Required | Description |
|-------|----------|-------------|
| `url` | Yes | Must be a valid URL |
| `events` | Yes | Array of event names (below) |
| `description` | No | Free text |
| `merchantId` | No | Only for tenant-level callers; a merchant-scoped caller always gets its own merchant |
| `maxRetries` | No | Default `3` |
| `retryDelaySeconds` | No | Default `60` |

**Response (`201`):** the endpoint plus `secret`, and `"message": "Store the signing secret now — it cannot be retrieved again."`

### Event types

| Event | When |
|-------|------|
| `transaction.approved` | Payment approved |
| `transaction.pending` | Payment accepted but not yet settled (e.g. ACH) |
| `transaction.declined` | Payment declined |
| `transaction.voided` | Transaction voided |
| `transaction.refunded` | Refund processed |
| `transaction.refund_failed` | Refund failed |
| `transaction.returned` | ACH debit returned by the bank (`data.returnCode` carries the R-code) |
| `session.cancelled` | Hosted checkout session cancelled |
| `session.expired` | Hosted checkout session expired |

`batch.resubmit` is a platform event delivered only to system endpoints and cannot be subscribed to from a merchant endpoint. Unknown names are rejected with `400` `UNKNOWN_EVENT_TYPE` (the message lists every invalid name and the valid set). See [Webhook Events](../reference/webhook-events.md) for payloads.

### Errors

| Status | Code | When |
|--------|------|------|
| `400` | `VALIDATION_ERROR` | `url` missing, or `events` missing or not an array |
| `400` | `UNKNOWN_EVENT_TYPE` | An event name is not in the list above |
| `400` | `INVALID_URL` | `url` is not a valid URL |
| `404` | `ENDPOINT_NOT_FOUND` | Endpoint not found (or not yours) |

---

## Testing an Endpoint

```http
POST /api/v1/webhooks/endpoints/:id/test
```

Sends a signed `webhook.test` event to the endpoint and returns the delivery result:

```json
{
  "success": true,
  "data": {
    "success": true,
    "deliveryId": "5f0c1d2e-8a9b-4c3d-9e1f-2a3b4c5d6e7f",
    "statusCode": 200,
    "responseTime": 184
  }
}
```

A failed delivery still returns `200` with `data.success: false` and an `error` message.

---

## Webhook Logs

View delivery history for an endpoint, newest first.

```http
GET /api/v1/webhooks/endpoints/:id/deliveries
```

| Query | Default | Description |
|-------|---------|-------------|
| `limit` | `50` | Page size |
| `offset` | `0` | Rows to skip |

```bash
curl "https://uat.auxcore.net/api/v1/webhooks/endpoints/7c9e6679-7425-40de-944b-e07fc1f90ae7/deliveries?limit=20" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

**Response:**
```json
{
  "success": true,
  "data": {
    "deliveries": [
      {
        "id": "5f0c1d2e-8a9b-4c3d-9e1f-2a3b4c5d6e7f",
        "eventType": "transaction.approved",
        "eventId": "0b7f5c2e-3d1a-4f6b-9e8c-1a2b3c4d5e6f",
        "attemptNumber": 2,
        "status": "retrying",
        "responseStatusCode": 503,
        "errorMessage": "HTTP 503: Service Unavailable",
        "responseTimeMs": 412,
        "nextRetryAt": "2026-01-28T15:03:00.000Z",
        "createdAt": "2026-01-28T15:00:00.000Z"
      }
    ],
    "pagination": { "total": 1, "limit": 20, "offset": 0, "hasMore": false }
  }
}
```

Delivery `status` is one of `pending`, `success`, `failed`, `retrying`.

### Retry a delivery manually

```http
POST /api/v1/webhooks/deliveries/:deliveryId/retry
```

Re-sends the delivery immediately (incrementing `attemptNumber`) and returns the same result shape as the test endpoint. The retry does not check whether the endpoint is still active, and it is counted as an attempt (if it fails and the attempts are already used up, no further automatic retry is scheduled). An unknown delivery, or one belonging to another tenant or merchant, returns `404` `NOT_FOUND`.

---

**Need help?** Contact support@auxcore.net
