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

# Webhooks

> Receive real-time HTTP notifications when events happen in your Crevio account — orders, payments, leads, AI task runs, email, and more.

**Webhooks push events to your server the moment they happen — no polling.** You register an HTTPS endpoint, choose which events to receive, and Crevio sends a signed JSON `POST` whenever one fires.

Use them to sync data, trigger your own automations, fulfill purchases, and feed analytics — anything where your code needs to react to something happening in Crevio.

<Note>
  Webhooks notify **your** server so **your** code acts. If you instead want **Crevio's AI** to act on an event, use an event-triggered [Task](/docs/developer/guides/tasks). See [How webhooks differ from event-triggered Tasks](#how-webhooks-differ-from-event-triggered-tasks).
</Note>

## Creating an endpoint

Create endpoints from the dashboard, via the [API](/docs/developer/guides/api-overview), or with the [TypeScript SDK](/docs/developer/guides/sdk).

### Via dashboard

<Steps>
  <Step title="Open Developer settings">
    In your dashboard, go to **Settings → Developer**.
  </Step>

  <Step title="Create a new endpoint">
    Click **New webhook endpoint** and enter the HTTPS URL that should receive events. (HTTP is allowed only in local development.)
  </Step>

  <Step title="Select events">
    Choose which event types to subscribe to, individually or all at once.
  </Step>

  <Step title="Save and copy the secret">
    Crevio generates a **signing secret** (prefixed `whsec_`) shown once. Copy it now — you'll use it to verify incoming requests.
  </Step>
</Steps>

### Via API

Like every Crevio endpoint, params go at the **top level** (no `{"webhook_endpoint": {...}}` wrapper). Subscribe with `enabled_events`.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X POST https://api.crevio.co/v1/webhook_endpoints \
      -H "Authorization: Bearer YOUR_API_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://example.com/webhooks/crevio",
        "status": "active",
        "enabled_events": ["order.paid", "checkout.created"]
      }'
    ```
  </Tab>

  <Tab title="SDK (TypeScript)">
    ```typescript theme={null}
    import { Crevio } from "@crevio/sdk";

    const crevio = new Crevio({ apiKeyAuth: "YOUR_API_TOKEN" });

    const endpoint = await crevio.webhookEndpoints.create({
      url: "https://example.com/webhooks/crevio",
      status: "active",
      enabledEvents: ["order.paid", "checkout.created"],
    });
    ```
  </Tab>
</Tabs>

The response includes a `secret` — returned **only on creation**:

```json theme={null}
{
  "id": "wh_abc123",
  "object": "webhook_endpoint",
  "url": "https://example.com/webhooks/crevio",
  "api_version": "v1",
  "status": "active",
  "enabled_events": ["order.paid", "checkout.created"],
  "secret": "whsec_...",
  "created_at": "2026-06-21T10:30:00Z",
  "updated_at": "2026-06-21T10:30:00Z"
}
```

<Warning>
  Copy your signing secret immediately. It's shown once on creation and cannot be retrieved later — you'll need it to verify signatures.
</Warning>

List the available event types programmatically:

```bash theme={null}
curl https://api.crevio.co/v1/webhook_endpoints/events \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

## Testing an endpoint

Send a synthetic event to any active endpoint to verify your handler:

```bash theme={null}
curl -X POST "https://api.crevio.co/v1/webhook_endpoints/wh_abc123/test?event_type=order.paid" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

`event_type` must be one the endpoint is subscribed to. A test delivery is signed like a real one, but it carries `"test": true` and an **empty** `data.object` — acknowledge it as soon as the signature checks out, or your own validation will fail a test that was never going to carry an order. The response is what your server answered, so a handler that rejects the event tells you why:

```json theme={null}
{
  "delivered": false,
  "response_code": 401,
  "response_body": "{\"error\":\"invalid signature\"}",
  "message": "HTTP 401: Unauthorized"
}
```

```javascript theme={null}
// verify first, then:
if (event.test) return new Response(null, { status: 204 });
```

`delivered` is `true` for any `2xx`. If nothing answers at all — DNS failure, refused connection, timeout — the call returns `502` instead. The test event is signed like a real one, so it exercises your signature check.

You can also test from the dashboard: **Settings → Developer → Send test**, then pick an event type. Test deliveries include `"test": true` in the body and are **not** persisted to your delivery history.

<Tip>
  Use [webhook.site](https://webhook.site) or [ngrok](https://ngrok.com) during development to inspect payloads without standing up a server.
</Tip>

## Available events

These are the event types you can subscribe to via `enabled_events`. Each has a dedicated page under **Webhook Events** in the [API reference](/docs/developer/api-reference/introduction) showing its exact payload.

### Commerce

| Event | Fires when |
| - | - |
| `checkout.created` | A checkout session is created |
| `order.created` | An order is created |
| `order.paid` | An order is paid |
| `refund.created` | A refund is issued |
| `refund.updated` | A refund's status changes |
| `invoice.created` | An invoice is created |
| `invoice.paid` | An invoice is paid |
| `invoice.past_due` | An invoice passes its due date |
| `invoice.voided` | An invoice is voided |
| `subscription.created` | A customer completes checkout for a subscription |
| `subscription.renewed` | A recurring payment succeeds |
| `subscription.canceled` | A subscription is set to end when its current period closes |
| `subscription.paused` | Payment collection is paused |
| `subscription.resumed` | A paused or canceled subscription is resumed |
| `subscription.past_due` | A renewal payment fails and retries begin |
| `subscription.ended` | A subscription has ended and access is revoked |

`subscription.canceled` means the customer keeps access until `ends_at`. A subscription that is canceled immediately skips it and sends only `subscription.ended`. These events fire however the change was made — the API, the customer's billing portal, or Stripe.

A subscription that starts with a free trial has no payment to tie it to an order, so its `order` is `null`. `customer` is always present.

### Leads & customers

| Event | Fires when |
| - | - |
| `lead.created` | A new lead is captured |
| `form_submission.created` | A form submission is received |
| `form_submission.confirmed` | A pending submission is confirmed (double opt-in) |
| `customer.confirmed` | A customer confirms their email (double opt-in) |
| `customer.tag_added` | A tag is added to a customer |
| `customer.tag_removed` | A tag is removed from a customer |

Filter on `data.tag.id` to react to one specific tag. Bulk CSV imports and deleting a tag outright do not send per-customer tag events.

### Catalog

| Event | Fires when |
| - | - |
| `product.created` | A product is created |
| `product.updated` | A product is updated |

### AI Tasks & Jobs

| Event | Fires when |
| - | - |
| `task.created` | An AI task is created |
| `task_run.completed` | A task run finishes successfully |
| `task_run.failed` | A task run fails |
| `task_run.needs_input` | A supervised task run is awaiting input |
| `job.completed` | A background job (e.g. media generation) completes |
| `job.failed` | A background job fails |

### Email

| Event | Fires when |
| - | - |
| `email_message.received` | An inbound email is received |
| `email_message.sent` | An email is sent |
| `email_message.delivered` | An email is delivered |
| `email_message.bounced` | An email bounces |
| `email_message.complained` | A recipient marks an email as spam |
| `email_message.failed` | An email fails to send |

### Social

| Event | Fires when |
| - | - |
| `x.mention.received` | New posts mention your connected X account. Polled every five minutes, and only while the account has an active task triggered by this event — see [Mentions of your X account](/docs/developer/guides/events#mentions-of-your-x-account) |

## Payload format

Every delivery is an HTTP `POST` with a JSON body in this shape:

```json theme={null}
{
  "id": "whev_abc123",
  "object": "event",
  "created_at": "2026-06-21T10:30:00Z",
  "type": "order.paid",
  "api_version": "v1",
  "data": {
    "object": {
      "id": "ord_xyz789",
      "object": "order",
      "status": "succeeded",
      "amount": 4900,
      "currency": "usd",
      "customer": { "id": "cus_abc123", "object": "customer", "email": "john@example.com" },
      "created_at": "2026-06-21T10:29:55Z"
    }
  }
}
```

| Field | Description |
| - | - |
| `id` | Unique id for this event (`whev_…`) — use it to deduplicate |
| `object` | Always `event` |
| `created_at` | ISO 8601 timestamp the event was created |
| `type` | The event type (e.g. `order.paid`) |
| `api_version` | The payload version this endpoint is pinned to — see [Versioning](#versioning) |
| `data.object` | The resource the event is about |

`data.object` is the resource exactly as the API returns it — an `order.paid` event carries the same object as `GET /v1/orders/{id}`, so one parser handles both. References are ids you can fetch (`checkout`, `order`), and the ones you almost always need arrive already expanded: `customer` on checkout, order, refund, invoice and subscription events, plus `line_items` on orders and `order.line_items` on subscriptions.

Refund events carry the refunded `order` as `data.object` — its `amount_refunded` and `status` reflect the refund.

Tag events carry the customer as `data.object` and the tag that was added or removed as `data.tag`.

## Delivery history

Each delivered event is persisted as a **webhook event** you can inspect. List your delivery history:

```bash theme={null}
curl https://api.crevio.co/v1/webhook_events \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

```json theme={null}
{
  "object": "list",
  "data": [
    {
      "id": "whev_abc123",
      "object": "webhook_event",
      "type": "order.paid",
      "created_at": "2026-06-21T10:30:00Z"
    }
  ],
  "has_more": false
}
```

Narrow the list to one endpoint with `?webhook_endpoint=wh_abc123`, or by `status` and `event_type`. Fetch a single delivery with `GET /webhook_events/{id}`; add `?expand[]=request` to see the response code, response body and attempt count.

### Redelivering an event

Send a delivered or failed event again, unchanged:

```bash theme={null}
curl -X POST https://api.crevio.co/v1/webhook_events/whev_abc123/redeliver \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

The redelivery carries the same `id` and the same payload, so a handler that deduplicates on `id` treats it as the event it already saw. Events are kept for **7 days**. The call returns `409` while the event is still being delivered, or when its endpoint is disabled.

## Versioning

Every endpoint is pinned to a payload version — `api_version` on the endpoint, repeated in each event. A new endpoint is pinned to the current version, and **that never changes for you**: when a payload changes in a way that could break a parser, it ships as a new version, and existing endpoints keep receiving the one they were built against. Opt in when you are ready:

```bash theme={null}
curl -X PATCH https://api.crevio.co/v1/webhook_endpoints/wh_abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"api_version": "v1"}'
```

New fields and new event types are not breaking changes and reach every version, so write parsers that ignore what they don't recognise. The current version is `v1`.

## Retry behavior

Crevio expects a `2xx` response within **5 seconds**. Redirects are not followed. Anything else — a timeout, a `4xx`, a `5xx` — counts as a failure, and the event is retried on this schedule:

| Attempt | Wait before it |
| - | - |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 8 minutes |
| 5 | 30 minutes |
| 6 | 1 hour |
| 7 | 3 hours |
| 8 | 6 hours |
| 9–13 | 12 hours each |

That is about **three days** in all. After the last attempt the event is marked **failed**; you can still [redeliver](#redelivering-an-event) it for 7 days.

Delivery is **at least once**, and **order is not guaranteed** — a retry of an older event can land after a newer one. Deduplicate on `id`, and fetch the resource from the API when order matters.

### When an endpoint keeps failing

* After **24 hours** in which every delivery failed, the account owner gets a warning email.
* After **72 hours**, the endpoint is switched to `inactive` and the owner is told. Events still waiting to retry are marked failed.

One successful delivery at any point resets both clocks. Once the receiving end is fixed, set the endpoint back to `active` and redeliver what it missed.

### Endpoint statuses

| Status | Meaning |
| - | - |
| `active` | Receiving events normally |
| `inactive` | Disabled — receives no events |

Deactivate an endpoint without deleting it; reactivating resumes delivery for new events. Crevio sets `inactive` itself only after [72 hours of failures](#when-an-endpoint-keeps-failing).

## Security

### Verifying signatures

Crevio signs deliveries to the [Standard Webhooks](https://www.standardwebhooks.com) specification, so you verify them with an existing library instead of writing HMAC code. Every request carries three headers:

| Header | Value |
| - | - |
| `webhook-id` | The event's id — the same on every retry, and the same as `id` in the body |
| `webhook-timestamp` | Unix seconds when this attempt was sent |
| `webhook-signature` | `v1,<signature>` |

<CodeGroup>
  ```javascript Node.js theme={null}
  // npm install standardwebhooks
  import { Webhook } from "standardwebhooks";

  const webhook = new Webhook(process.env.CREVIO_WEBHOOK_SECRET); // the whole whsec_… string

  export async function POST(request) {
    const rawBody = await request.text(); // never request.json() — verification needs the exact bytes

    let event;
    try {
      event = webhook.verify(rawBody, Object.fromEntries(request.headers));
    } catch {
      return new Response("invalid signature", { status: 401 });
    }

    if (event.type === "order.paid") {
      const order = event.data.object;
      // …
    }
    return new Response(null, { status: 204 });
  }
  ```

  ```python Python theme={null}
  # pip install standardwebhooks
  from standardwebhooks.webhooks import Webhook

  webhook = Webhook(os.environ["CREVIO_WEBHOOK_SECRET"])  # the whole whsec_… string

  def handle(raw_body: bytes, headers: dict):
      event = webhook.verify(raw_body, headers)  # raises WebhookVerificationError
      ...
  ```

  ```ruby Ruby theme={null}
  # gem "standardwebhooks"
  webhook = StandardWebhooks::Webhook.new(ENV.fetch("CREVIO_WEBHOOK_SECRET"))

  # Rails: raises StandardWebhooks::WebhookVerificationError on a bad signature
  event = webhook.verify(request.raw_post, {
    "webhook-id" => request.headers["webhook-id"],
    "webhook-timestamp" => request.headers["webhook-timestamp"],
    "webhook-signature" => request.headers["webhook-signature"]
  })
  ```
</CodeGroup>

The libraries also reject a `webhook-timestamp` more than five minutes old, which is what stops a captured request being replayed.

<Accordion title="Verifying without a library">
  The signature is the **base64** HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<raw body>`. The key is the bytes your secret encodes: strip the `whsec_` prefix and base64-decode the rest — do not use the secret's text as the key. Compare in constant time against the part of `webhook-signature` after `v1,`, and reject a timestamp more than five minutes old.
</Accordion>

<Warning>
  Always verify the signature before processing a payload. Without it, anyone could forge events to your endpoint.
</Warning>

### Best practices

* **Respond fast** — return `200` immediately, then process asynchronously to avoid timeouts.
* **Deduplicate** — use the event `id` (`whev_…`); the same event may arrive more than once.
* **Use HTTPS** — required in production.
* **Protect your secret** — store it in env vars or a secrets manager; never commit it.

## Output formats

Beyond the default JSON, Crevio can format deliveries for chat platforms:

| Format | Use case |
| - | - |
| Raw (default) | Standard JSON for your own server |
| Slack | Pre-formatted for Slack incoming webhooks |
| Discord | Pre-formatted for Discord webhook URLs |

Select the format when creating or editing the endpoint.

## How webhooks differ from event-triggered Tasks

Both react to platform events, but they do different jobs:

| | Webhook | Event-triggered [Task](/docs/developer/guides/tasks) |
| - | - | - |
| Who acts | Your server / your code | Crevio's AI agent |
| You provide | An HTTPS endpoint | A natural-language prompt |
| Best for | Syncing, custom fulfillment, analytics | Autonomous follow-ups, drafting, decisions |
| Setup | `POST /webhook_endpoints` | `POST /tasks` with `trigger_type: "event"` |

The subscribable internal events (and any third-party triggers) are documented under [Events](/docs/developer/guides/events).

## Next steps

<CardGroup cols={2}>
  <Card title="Events" icon="bolt" href="/docs/developer/guides/events">
    Internal and third-party triggers for event Tasks.
  </Card>

  <Card title="Tasks" icon="robot" href="/docs/developer/guides/tasks">
    Let Crevio's AI react to events on your behalf.
  </Card>

  <Card title="Autonomous outreach" icon="envelope" href="/docs/developer/guides/recipe-autonomous-outreach">
    A worked event-triggered Task on `order.paid`.
  </Card>

  <Card title="API overview" icon="book" href="/docs/developer/guides/api-overview">
    The full set of primitives.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.