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

# Webhooks

> Receive real-time notifications when important business events happen in your SolvaPay account.

## Overview

SolvaPay webhooks push event notifications to your application via HTTP POST requests whenever
significant business events occur — a payment succeeds, a purchase is cancelled, a customer
is created, and so on.

Instead of polling the API, you register an endpoint URL and SolvaPay delivers a signed JSON
payload for each event. Your server verifies the signature, processes the event, and returns
a `2xx` response.

### How it works

```
SolvaPay Backend
    ↓  event occurs (payment, purchase, customer)
Enqueue WebhookEvent + WebhookDelivery
    ↓  cron every 30 s, up to 20 per batch
POST to your endpoint
    ↓
Your server verifies SV-Signature
    ↓
Process event
    ↓
Return 2xx
```

***

## Quick Start

### 1. Install the SDK

```bash theme={null}
npm install @solvapay/server
```

### 2. Create a webhook endpoint

In SolvaPay Console, go to **Settings -> Webhooks** and add your endpoint URL:

```
https://your-app.com/api/webhooks/solvapay
```

Copy the signing secret (`whsec_…`) — you will need it to verify signatures.

### 3. Handle incoming webhooks

<CodeGroup>
  ```typescript Next.js (App Router) theme={null}
  // app/api/webhooks/solvapay/route.ts
  import { NextRequest, NextResponse } from 'next/server'
  import { verifyWebhook } from '@solvapay/server'

  export async function POST(request: NextRequest) {
    const body = await request.text()
    const signature = request.headers.get('sv-signature')

    if (!signature) {
      return NextResponse.json({ error: 'Missing signature' }, { status: 401 })
    }

    try {
      const event = verifyWebhook({
        body,
        signature,
        secret: process.env.SOLVAPAY_WEBHOOK_SECRET!,
      })

      switch (event.type) {
        case 'payment.succeeded':
          // fulfil the order
          break
        case 'purchase.cancelled':
          // revoke access
          break
        default:
          console.log('Unhandled event:', event.type)
      }

      return NextResponse.json({ received: true })
    } catch (error) {
      console.error('Webhook verification failed:', error)
      return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
    }
  }
  ```

  ```typescript Express.js theme={null}
  // routes/webhooks.ts
  import express from 'express'
  import { verifyWebhook } from '@solvapay/server'

  const router = express.Router()

  router.post(
    '/solvapay',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
      const signature = req.headers['sv-signature'] as string

      if (!signature) {
        return res.status(401).json({ error: 'Missing signature' })
      }

      try {
        const event = verifyWebhook({
          body: req.body.toString(),
          signature,
          secret: process.env.SOLVAPAY_WEBHOOK_SECRET!,
        })

        switch (event.type) {
          case 'payment.succeeded':
            break
          case 'purchase.cancelled':
            break
          default:
            console.log('Unhandled event:', event.type)
        }

        res.json({ received: true })
      } catch (error) {
        console.error('Webhook verification failed:', error)
        res.status(401).json({ error: 'Invalid signature' })
      }
    },
  )

  export default router
  ```
</CodeGroup>

<Warning>
  Always use `request.text()` (Next.js) or `express.raw()` (Express) so the body is available
  as a raw string. If the body has been JSON-parsed before verification, the signature check
  will fail.
</Warning>

***

## Payload Format

Every webhook POST contains a JSON body with this structure:

```json theme={null}
{
  "id": "evt_abc123def456",
  "type": "payment.succeeded",
  "created": 1740000000,
  "api_version": "2025-10-01",
  "data": {
    "object": { ... },
    "previous_attributes": null
  },
  "livemode": false,
  "request": {
    "id": null,
    "idempotency_key": null
  }
}
```

### HTTP Headers

| Header         | Example                    | Description                                      |
| -------------- | -------------------------- | ------------------------------------------------ |
| `SV-Signature` | `t=1740000000,v1=a1b2c3…`  | HMAC signature for verification                  |
| `SV-Event-Id`  | `evt_abc123def456`         | Unique event identifier                          |
| `SV-Delivery`  | `dlv_xyz789`               | Unique delivery attempt ID (use for idempotency) |
| `Content-Type` | `application/json`         | Always JSON                                      |
| `User-Agent`   | `SolvaPay/1.0 (+webhooks)` | Identifies the sender                            |

***

## Signature Verification

Every webhook includes an `SV-Signature` header. You **must** verify it to confirm the
request genuinely came from SolvaPay and has not been tampered with.

### How the signature is computed

1. SolvaPay takes the current Unix timestamp and the raw JSON body.
2. It concatenates them as `"{timestamp}.{rawBody}"`.
3. It computes an HMAC-SHA256 of that string using your signing secret (including the
   `whsec_` prefix) as the key.
4. The result is sent as `t={timestamp},v1={hex_digest}`.

### Using the SDK

`verifyWebhook` returns a typed `WebhookEvent` object with full IntelliSense for event
types and payload fields:

```typescript theme={null}
import { verifyWebhook, type WebhookEvent } from '@solvapay/server'

const event: WebhookEvent = verifyWebhook({
  body: rawBodyString,
  signature: svSignatureHeader,
  secret: process.env.SOLVAPAY_WEBHOOK_SECRET!,
})

// event.type is typed as WebhookEventType — autocomplete for every event name
// event.data.object contains the event-specific payload
```

`verifyWebhook` throws a `SolvaPayError` if the signature is invalid or older than 5 minutes.

### Edge Runtimes

For Vercel Edge Functions, Cloudflare Workers, or Deno Deploy, import from the edge entry point. It uses the Web Crypto API and returns a `Promise`:

```typescript theme={null}
import { verifyWebhook } from '@solvapay/server/edge'

const event = await verifyWebhook({
  body: rawBodyString,
  signature: svSignatureHeader,
  secret: process.env.SOLVAPAY_WEBHOOK_SECRET!,
})
```

### Manual verification (without the SDK)

```typescript theme={null}
import crypto from 'node:crypto'

function verify(body: string, header: string, secret: string): boolean {
  const parts = header.split(',')
  const tPart = parts.find(p => p.startsWith('t='))
  const v1Part = parts.find(p => p.startsWith('v1='))
  if (!tPart || !v1Part) return false

  const timestamp = tPart.slice(2)
  const signature = v1Part.slice(3)

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  )
}
```

***

## Event Types

SolvaPay emits **46 event types** spanning the full lifecycle — from customer and
purchase creation through payments, refunds, disputes, payouts, credits, metered usage,
and catalog changes. The canonical list is also available programmatically from
`GET /v1/sdk/webhooks/event-types` and is what powers both the SDK `WebhookEventType`
type and the event multiselect in the Console.

<Tip>
  By default an endpoint receives **all** events. To narrow it down, see
  [Choosing which events to receive](#choosing-which-events-to-receive).
</Tip>

### Customer Events

| Event              | Description                                                             |
| ------------------ | ----------------------------------------------------------------------- |
| `customer.created` | A new customer record has been created (hosted auth, API, or SDK sync). |
| `customer.updated` | A customer record has been updated.                                     |
| `customer.deleted` | A customer record has been deleted.                                     |

### Purchase & Subscription Events

The complete purchase/subscription state machine:

| Event                             | Description                                                                    |
| --------------------------------- | ------------------------------------------------------------------------------ |
| `purchase.created`                | A new purchase (subscription or one-time) was created. Includes trial starts.  |
| `purchase.activated`              | A purchase activated after its first successful payment.                       |
| `purchase.updated`                | A purchase was modified (generic change not covered by a more specific event). |
| `purchase.trial_ending`           | A trial is ending soon.                                                        |
| `purchase.trial_converted`        | A trial converted to a paid subscription.                                      |
| `purchase.suspended`              | A purchase was suspended (trial ended, payment required).                      |
| `purchase.past_due`               | A recurring payment failed and dunning has started.                            |
| `purchase.cancellation_scheduled` | Cancellation scheduled for the end of the billing period.                      |
| `purchase.cancelled`              | A purchase was cancelled.                                                      |
| `purchase.reactivated`            | A scheduled cancellation was reversed.                                         |
| `purchase.expired`                | A purchase expired (naturally, or superseded by a plan switch).                |
| `purchase.renewed`                | A subscription successfully renewed (recurring charge).                        |
| `purchase.renewal_reminder`       | The upcoming-renewal reminder window was reached.                              |
| `purchase.refunded`               | A purchase's entitlement was revoked by a full refund.                         |
| `purchase.plan_changed`           | A subscription was upgraded or downgraded to a new plan.                       |

### Payment, Refund & Dispute Events

| Event                    | Description                                    |
| ------------------------ | ---------------------------------------------- |
| `payment.succeeded`      | A payment was successfully processed.          |
| `payment.failed`         | A payment attempt failed.                      |
| `payment.refunded`       | A refund was successfully processed.           |
| `payment.refund_failed`  | A refund attempt failed.                       |
| `payment.refund_pending` | A refund was initiated and is still in-flight. |
| `payment.canceled`       | A payment intent was canceled.                 |
| `payment.disputed`       | A chargeback/dispute was opened.               |
| `payment.dispute_closed` | A dispute was resolved (won or lost).          |

### Payout Events

| Event           | Description                |
| --------------- | -------------------------- |
| `payout.paid`   | A provider payout settled. |
| `payout.failed` | A provider payout failed.  |

### Checkout Session Events

| Event                        | Description                                          |
| ---------------------------- | ---------------------------------------------------- |
| `checkout_session.created`   | A hosted checkout session was created.               |
| `checkout_session.completed` | A hosted checkout session was completed (converted). |
| `checkout_session.expired`   | A hosted checkout session expired (abandoned).       |

### Credit Events

| Event                         | Description                                        |
| ----------------------------- | -------------------------------------------------- |
| `customer.credit.topped_up`   | A customer's credit balance was topped up.         |
| `customer.credit.low_balance` | A customer's credit balance is running low.        |
| `customer.credit.exhausted`   | A customer's credit balance reached zero.          |
| `customer.credit.debited`     | A customer's credit was debited by usage.          |
| `customer.credit.granted`     | Free credit was granted to a customer.             |
| `customer.credit.adjusted`    | A customer's credit balance was manually adjusted. |

### Usage & Metering Events

| Event            | Description                                   |
| ---------------- | --------------------------------------------- |
| `usage.charged`  | A metered / usage-based charge was processed. |
| `usage.recorded` | A metered usage event was recorded.           |
| `usage.reset`    | Usage counters were reset for a new period.   |

### Catalog Events

| Event              | Description             |
| ------------------ | ----------------------- |
| `product.created`  | A product was created.  |
| `product.updated`  | A product was updated.  |
| `product.archived` | A product was archived. |
| `plan.created`     | A plan was created.     |
| `plan.updated`     | A plan was updated.     |
| `plan.archived`    | A plan was archived.    |

<Info>
  **Reactivation**: undoing a pending cancellation via `reactivateRenewal` emits
  `purchase.reactivated`.

  **Plan switching**: calling `activatePlan` with a different plan emits `purchase.expired`
  for the old purchase, `purchase.created` for the new one, and `purchase.plan_changed`.
</Info>

***

## Choosing which events to receive

By default, **every endpoint receives all event types**. You can subscribe an endpoint to
a specific subset so your handler only gets the events it cares about.

### In the Console

Under **Settings → Webhooks**, expand an endpoint and pick **Selected events** to choose
specific event types (grouped by category). Leave it on **All events** to receive
everything — including any new event types SolvaPay adds later.

### Via the API

Pass `enabledEvents` when creating or updating an endpoint. An **empty or omitted** array
means "all events"; a non-empty array subscribes to only those types.

```bash theme={null}
# Subscribe to only payment + purchase lifecycle events
curl -X POST https://api.solvapay.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/api/webhooks/solvapay",
    "enabledEvents": [
      "payment.succeeded",
      "payment.failed",
      "purchase.created",
      "purchase.cancelled",
      "purchase.past_due"
    ]
  }'
```

<Tip>
  Subscribing to a subset is purely a delivery filter — it does not change the payload shape
  or signing. Endpoints left on "all events" automatically receive new event types as they
  are introduced, so you never miss a future event.
</Tip>

***

## Event Payloads

### payment.succeeded

```json theme={null}
{
  "type": "payment.succeeded",
  "data": {
    "object": {
      "id": "pi_abc123",
      "amount": 2999,
      "currency": "usd",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "plan": "plan_ref_001",
      "stripe_payment_intent": "pi_3abc...",
      "transaction_reference": "txn_ref_001",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### payment.failed

```json theme={null}
{
  "type": "payment.failed",
  "data": {
    "object": {
      "id": "pi_abc123",
      "amount": 2999,
      "currency": "usd",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "plan": "plan_ref_001",
      "stripe_payment_intent": "pi_3abc...",
      "transaction_reference": "txn_ref_001",
      "failure_reason": "Your card was declined.",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### payment.refunded

```json theme={null}
{
  "type": "payment.refunded",
  "data": {
    "object": {
      "id": "txn_abc123",
      "amount": 2999,
      "currency": "usd",
      "stripe_refund": "re_3abc...",
      "reason": "requested_by_customer",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### payment.refund\_failed

```json theme={null}
{
  "type": "payment.refund_failed",
  "data": {
    "object": {
      "id": "txn_abc123",
      "amount": 2999,
      "currency": "usd",
      "stripe_refund": "re_3abc...",
      "reason": "requested_by_customer",
      "failure_reason": "charge_for_pending_refund_disputed",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### purchase.created

```json theme={null}
{
  "type": "purchase.created",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "active",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### purchase.updated

```json theme={null}
{
  "type": "purchase.updated",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "active",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### purchase.cancelled

```json theme={null}
{
  "type": "purchase.cancelled",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "cancelled",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### purchase.expired

```json theme={null}
{
  "type": "purchase.expired",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "expired",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### purchase.suspended

```json theme={null}
{
  "type": "purchase.suspended",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "suspended",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "amount": 2999,
      "currency": "usd",
      "billingCycle": "monthly",
      "isRecurring": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### customer.created

This payload can include a `product` field with a product `reference`.

SolvaPay only populates `product` when the customer is created through the no-code MCP integration's
hosted OAuth for a product-linked client.

To learn more about SDK customer syncing, see
[`/sdks/typescript/setup/core-concepts`](/sdks/typescript/setup/core-concepts).

```json theme={null}
{
  "type": "customer.created",
  "data": {
    "object": {
      "id": "cust_abc123",
      "reference": "customer_abc123",
      "name": "Jane Doe",
      "email": "jane@example.com",
      "telephone": "+1234567890",
      "status": "active",
      "created": 1740000000,
      "product": {
        "reference": "prod_ref_001"
      }
    },
    "previous_attributes": null
  }
}
```

### customer.updated

```json theme={null}
{
  "type": "customer.updated",
  "data": {
    "object": {
      "id": "cust_abc123",
      "reference": "customer_abc123",
      "name": "Jane Smith",
      "email": "jane.smith@example.com",
      "telephone": "+1234567890",
      "status": "active",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### customer.deleted

The `customer.deleted` payload contains only the customer `id` and a `created` timestamp.
Other fields are not included since the record has been removed.

```json theme={null}
{
  "type": "customer.deleted",
  "data": {
    "object": {
      "id": "cust_abc123",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### Customer sync with the TypeScript SDK

When you integrate with the TypeScript SDK (instead of the no-code MCP integration's hosted OAuth),
sync your users with `ensureCustomer` or the Next.js `syncCustomer` helper.

This flow is idempotent. SolvaPay first looks up a customer by `externalRef`, then creates one
if none exists.

When a new customer is created this way, SolvaPay sends a `customer.created` webhook event with
`product` set to `null`.

```typescript theme={null}
// Server SDK
const customerRef = await solvaPay.ensureCustomer('user_123', 'user_123', {
  email: 'user@example.com',
  name: 'Jane Doe',
})
```

```typescript theme={null}
// Next.js route handler
import { NextRequest, NextResponse } from 'next/server'
import { syncCustomer } from '@solvapay/next'

export async function POST(request: NextRequest) {
  const result = await syncCustomer(request)
  return result instanceof NextResponse ? result : NextResponse.json(result)
}
```

For implementation details, see
[`/sdks/typescript/setup/core-concepts`](/sdks/typescript/setup/core-concepts)
and [`/sdks/typescript/guides/nextjs`](/sdks/typescript/guides/nextjs).

### checkout\_session.created

```json theme={null}
{
  "type": "checkout_session.created",
  "data": {
    "object": {
      "id": "cs_abc123",
      "session_id": "a1b2c3d4e5f6...",
      "url": "https://solvapay.com/customer/checkout?id=a1b2c3d4e5f6...",
      "status": "active",
      "amount": 2999,
      "currency": "usd",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "plan": "plan_ref_001",
      "expires_at": 1740000900,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

`checkout_session.completed` and `checkout_session.expired` carry the **same object shape**,
with `status` set to `used` and `expired` respectively.

### Purchase lifecycle events

All `purchase.*` events share the purchase object shape shown under
[`purchase.created`](#purchase-created); `status` reflects the new state. Two events add
extra context:

```json theme={null}
// purchase.past_due — a recurring charge failed; dunning has begun
{
  "type": "purchase.past_due",
  "data": {
    "object": {
      "id": "pur_abc123",
      "reference": "purchase_abc123",
      "status": "past_due",
      "customer": "cust_ref_001",
      "plan": "plan_ref_001",
      "product": "prod_ref_001",
      "retryCount": 1,
      "nextRetryAt": 1740018000,
      "created": 1740000000
    },
    "previous_attributes": { "status": "active" }
  }
}
```

```json theme={null}
// purchase.plan_changed — upgrade/downgrade; previous_attributes shows the old plan
{
  "type": "purchase.plan_changed",
  "data": {
    "object": {
      "id": "pur_def456",
      "reference": "purchase_def456",
      "status": "active",
      "customer": "cust_ref_001",
      "plan": "plan_pro_001",
      "product": "prod_ref_001",
      "created": 1740000000
    },
    "previous_attributes": { "plan": "plan_basic_001" }
  }
}
```

### Dispute & payout events

```json theme={null}
// payment.disputed — a chargeback was opened (payment.dispute_closed has the same shape)
{
  "type": "payment.disputed",
  "data": {
    "object": {
      "id": "dp_abc123",
      "amount": 2999,
      "currency": "usd",
      "reason": "fraudulent",
      "status": "needs_response",
      "stripe_dispute": "dp_3abc...",
      "stripe_charge": "ch_3abc...",
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

```json theme={null}
// payout.paid — a provider payout settled (payout.failed has the same shape)
{
  "type": "payout.paid",
  "data": {
    "object": {
      "id": "po_abc123",
      "amount": 184500,
      "currency": "usd",
      "status": "paid",
      "stripe_payout": "po_3abc...",
      "arrival_date": 1740086400,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

### Credit events

```json theme={null}
// customer.credit.debited — credits consumed by usage
// (granted / adjusted / topped_up / low_balance / exhausted share this shape)
{
  "type": "customer.credit.debited",
  "data": {
    "object": {
      "customerId": "cust_ref_001",
      "providerId": "prov_001",
      "credits": 120,
      "amount": -5,
      "productRef": "prod_ref_001"
    },
    "previous_attributes": null
  }
}
```

`credits` is the running balance after the movement; `amount` is the signed delta.
`customer.credit.low_balance` and `customer.credit.exhausted` fire automatically as the
balance crosses the warning threshold and zero.

### Usage & metering events

```json theme={null}
// usage.recorded — a metered usage event was recorded
{
  "type": "usage.recorded",
  "data": {
    "object": {
      "reference": "usg_abc123",
      "customer": "cust_ref_001",
      "product": "prod_ref_001",
      "purchase": "purchase_abc123",
      "quantity": 1,
      "meterName": "api_requests",
      "recordedAt": 1740000000
    },
    "previous_attributes": null
  }
}
```

`usage.reset` fires when counters roll over for a new billing period.

### Catalog events

```json theme={null}
// product.created — product.updated / product.archived share this shape
{
  "type": "product.created",
  "data": {
    "object": {
      "id": "prd_abc123",
      "reference": "prod_ref_001",
      "name": "AI Writing Assistant",
      "status": "active",
      "productType": "mcp",
      "isMcpPay": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

```json theme={null}
// plan.created — plan.updated / plan.archived share this shape
{
  "type": "plan.created",
  "data": {
    "object": {
      "id": "pln_abc123",
      "reference": "plan_ref_001",
      "name": "Pro",
      "status": "active",
      "kind": "recurring",
      "price": 2999,
      "currency": "usd",
      "requiresPayment": true,
      "created": 1740000000
    },
    "previous_attributes": null
  }
}
```

***

## Best Practices

<AccordionGroup>
  <Accordion title="Always verify signatures">
    Never process a webhook without checking `SV-Signature`. Without verification, any
    third party could forge requests to your endpoint.
  </Accordion>

  <Accordion title="Use the delivery ID for idempotency">
    The `SV-Delivery` header is unique per delivery attempt. Store processed delivery IDs
    and skip duplicates to avoid processing the same event twice.
  </Accordion>

  <Accordion title="Respond within 10 seconds">
    Return a `2xx` as quickly as possible. If your handler needs to do heavy processing
    (e.g. sending emails, updating external systems), acknowledge the webhook immediately
    and move the work to a background queue.
  </Accordion>

  <Accordion title="Handle unknown events gracefully">
    Log and return `200` for event types you don't recognise. This way the delivery is
    marked successful and SolvaPay won't retry it.
  </Accordion>

  <Accordion title="Keep the raw body intact">
    Signature verification requires the exact body bytes. Always read the body as a raw
    string (`request.text()` or `express.raw()`) before parsing it as JSON.
  </Accordion>
</AccordionGroup>

***

## Retry Schedule

If your endpoint returns a non-2xx status code or times out, SolvaPay retries delivery with
exponential backoff:

| Retry | Delay after previous |
| ----- | -------------------- |
| 1     | 5 minutes            |
| 2     | 15 minutes           |
| 3     | 1 hour               |
| 4     | 6 hours              |
| 5     | 24 hours             |
| 6     | 48 hours             |
| 7+    | 72 hours             |

After **12 failed attempts** the endpoint is automatically disabled. You can re-enable it
from SolvaPay Console.

***

## Testing Locally

### Using ngrok

```bash theme={null}
# Start your local server
npm run dev

# In another terminal
ngrok http 3000

# Copy the HTTPS URL and add it as a webhook endpoint in the dashboard
```

### Sending a test event

Use the dashboard test button, or call the API directly:

```bash theme={null}
curl -X POST https://api.solvapay.com/v1/ui/webhooks/{endpointId}/test \
  -H "Authorization: Bearer $TOKEN"
```

This sends a `payment.succeeded` test event to the endpoint so you can verify your handler
and signature verification are working correctly.
