# Guide to Ampersand Webhooks: Features and Best Practices

Ampersand webhooks bring your customers' SaaS data, and news about their integrations, to your own service. Ampersand is a deep-integration platform: you declare which objects to read from or subscribe to in Salesforce, HubSpot and other systems, and Ampersand delivers the records to a destination you choose. With a webhook destination, those records arrive as HTTP requests, alongside notifications such as a customer installing your integration or a connection failing.

This guide covers how Ampersand webhook destinations are created and wired up, the two kinds of message they receive, how deliveries are signed, and the practices that keep an Ampersand receiver reliable.

## What are Ampersand webhooks?

In Ampersand, a webhook is a destination: a name and an HTTPS URL. You refer to the destination by name in your `amp.yaml` file, on the read or subscribe objects whose data it should receive, and you can route notifications to it through topics.

A webhook destination therefore receives two kinds of message:

* **Read and subscribe results.** The records Ampersand read or received from a customer's SaaS instance, identified by `action` (`read` or `subscribe`).
* **Notifications.** Lifecycle events about installations, connections, reads, writes and destinations, identified by `notificationType`.

Ampersand delivers webhooks through Svix, "which is why the signature is sent in the `svix-signature` header". Each destination has its own Webhook Signing Secret, which starts with `whsec_`.

## Ampersand webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (**Destinations**) or `POST /v1/projects/{project}/destinations` |
| Endpoint URL | Must start with `https` |
| Data routing | Per object, in `amp.yaml` (`destination: <name>`) |
| Notification routing | Event types to topics, topics to destinations |
| Message kinds | Results (`action`) and notifications (`notificationType`) |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | Svix (Standard Webhooks): HMAC-SHA256, base64, entries prefixed with `v1,` |
| Signed content | `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | Webhook Signing Secret (`whsec_...`), one per destination, rotatable |
| Payload size | At most 300 KB. Larger records arrive as a download URL that expires after 15 minutes |
| Throughput | At most 1000 messages per second across all destinations by default |
| Auto-disable | A webhook destination can be disabled after repeated failed deliveries |
| Retries and source IPs | Not published |

## Common events

### Read and subscribe results

Results carry the context of the installation and the records themselves. Here is Ampersand's subscribe example, trimmed:

```json
{
  "action": "subscribe",
  "projectId": "948234676-4874-43a4-beea-fa8081d13a07",
  "provider": "salesforce",
  "groupRef": "webhook-demo-group-id",
  "consumerRef": "user-123",
  "installationId": "0c9230e1c-8fbe-4b28-bf10-2beee8fbf4ce",
  "objectName": "company",
  "rawObjectName": "account",
  "result": [
    {
      "fields": { "id": "001Dp00000ZDgmxIAD", "website": null },
      "mappedFields": { "accountName": "johnAmp" },
      "subscribeEventType": "update",
      "providerEventType": "UPDATE",
      "raw": { "Id": "001Dp00000ZDgmxIAD", "Name": "johnAmp" }
    }
  ],
  "resultInfo": { "numRecords": 1, "type": "inline" }
}

```

* `groupRef` and `consumerRef` identify your customer and their user. Use them to find the tenant in your system.
* Read results add `operationId`, `operationTime` and `readType`, which is `backfill`, `scheduled` or `triggered`.
* Each subscribe result entry adds `subscribeEventType` (`create`, `update`, `delete`, or `associationUpdate` for HubSpot), the provider's own `providerEventType`, and the provider's `rawEvent` when available.
* When `resultInfo.type` is `inline`, the records are in `result`. When it is `url`, `result` is absent and you fetch the records from `resultInfo.downloadUrl`.

A large read can arrive as several messages in quick succession, each with a subset of the records.

### Notifications

Notifications to a webhook destination are `{ "notificationType": "...", "data": { ... } }`:

| Area | Notification types |
| --- | --- |
| Installations | `installation.created`, `installation.updated`, `installation.deleted` |
| Reads | `read.backfill.done`, `read.backfill.error`, `read.schedule.paused`, `read.triggered.done`, `read.triggered.error` |
| Writes | `write.async.done` |
| Subscriptions | `subscribe.create.success`, `subscribe.update.success`, `subscribe.delete.success`, `subscribe.create.error`, `subscribe.update.error`, `subscribe.delete.error` |
| Connections | `connection.created`, `connection.refreshed`, `connection.updated`, `connection.deleted`, `connection.error` |
| Destinations | `destination.webhook.disabled`, `destination.delivery.error` |

```json
{
  "notificationType": "connection.error",
  "data": {
    "projectId": "8f4a2b1c-3d5e-4f6a-9b0c-1d2e3f4a5b6c",
    "connectionId": "c4e7f2a1-8b3d-4f6e-9a2c-5d1e3f4b6a7c",
    "provider": "salesforce",
    "groupRef": "customer-group-ref",
    "consumerRef": "user-123",
    "errors": ["Authentication failed", "Token refresh error"]
  }
}

```

Installation notifications can include the full installation `config`, and `installation.updated` adds `lastConfig`. When a config is too large to include, Ampersand sends a signed `configURL` or `lastConfigURL` instead. On `connection.error`, use `errors`: the `error` field is deprecated.

## Setting up Ampersand webhooks

### Create the destination

1. In the Ampersand dashboard, go to **Destinations** and create a destination.
2. Give it a name, such as `accountWebhook`, and enter your HTTPS URL.
3. Open the destination and click **Click here to view the secret**. Store the Webhook Signing Secret server-side as `AMPERSAND_WEBHOOK_SECRET`.

Or create it through the API:

```bash
curl -X POST "https://api.withampersand.com/v1/projects/$AMPERSAND_PROJECT/destinations" \
  -H "X-Api-Key: $AMPERSAND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "accountWebhook", "type": "webhook", "metadata": {"url": "https://example.com/webhooks/ampersand"}}'

```

### Route data and notifications to it

For read and subscribe actions, name the destination on each object in `amp.yaml`:

```yaml
specVersion: 1.0.0
integrations:
  - name: readFromSalesforce
    provider: salesforce
    read:
      objects:
        - objectName: account
          destination: accountWebhook

```

For notifications, go to **Notifications**, click **New Topic**, choose the event types, and select the destination. Through the API, you create the topic and then the routes from event types to the topic and from the topic to the destination.

Dev and prod are usually separate Ampersand projects, each with its own destinations and secrets.

**With Hookdeck.** Create a source with the Ampersand source type, paste the whole `whsec_` secret into **Webhook Signing Secret**, and use the Hookdeck source URL as the destination URL. A delivery that fails the check still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Ampersand webhooks

To verify a delivery, join `svix-id`, `svix-timestamp` and the raw request body with dots. Take the part of the secret after `whsec_`, base64-decode it, and use the bytes as the HMAC-SHA256 key. Encode the result as base64 and compare it in constant time with each `v1,` entry in the space-delimited `svix-signature` header. A match on any entry is enough.

Ampersand names no timestamp tolerance. The check below is optional and off by default. If you turn it on, remember that a delivery retried or replayed later, for example from Hookdeck, keeps its original timestamp.

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

function verifyAmpersandSignature(rawBody, headers, secret, toleranceSeconds = 0) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
  if (!/^\d+$/.test(timestamp)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

// Allow for Ampersand's 300 KB maximum payload, with some headroom.
app.post('/webhooks/ampersand', express.raw({ type: '*/*', limit: '350kb' }), (req, res) => {
  if (!verifyAmpersandSignature(req.body, req.headers, process.env.AMPERSAND_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const message = JSON.parse(req.body.toString('utf8'));
  // message.notificationType -> notification; otherwise route on message.action.
  // Deduplicate on the svix-id header.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hashlib
import hmac
import time

def verify_ampersand_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
        return False
    if not timestamp.isdigit():
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    try:
        key = base64.b64decode(secret[len("whsec_"):])
    except (binascii.Error, ValueError):
        return False
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_ampersand_signature(raw_body, request.headers, os.environ["AMPERSAND_WEBHOOK_SECRET"])

```

Ampersand recommends the Svix libraries, which do the same check but always enforce a five-minute timestamp window. A delivery that Hookdeck retries or replays more than five minutes after Ampersand sent it fails the library's check even though its signature is valid. The [Ampersand webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/ampersand-webhooks) includes a manual verifier where you can drop or widen the window.

> **Make Ampersand webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every Ampersand delivery, absorbs backfill bursts, and delivers results and notifications to your services at a rate they can handle.

## Ampersand webhook limitations and pain points

### Download URLs expire after 15 minutes

**The Problem:** A message arrives with no records, and by the time you fetch them the link no longer works.

**Why It Happens:** A single record over 300 KB is too big for a webhook, so Ampersand sends `resultInfo.type: "url"` with a signed `downloadUrl` instead. The URL expires after 15 minutes. A message that waits in a retry queue, or is replayed later, carries a dead link.

**Workarounds:**

* Fetch `downloadUrl` as soon as the message arrives, before any other processing, and store the result.
* If the link has expired, trigger a new read through Ampersand's API for that object.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can send `url` results to a dedicated destination that only downloads and stores the file, so a slow downstream service never holds up the fetch.

### Backfills arrive in bursts

**The Problem:** A new customer's first sync floods your endpoint.

**Why It Happens:** A backfill can produce many messages in quick succession, and Ampersand sends up to 1000 messages per second across all your destinations by default.

**Workarounds:**

* Queue messages in your receiver and process them separately from the HTTP request.
* Contact Ampersand support if you need the 1000-per-second limit changed.

**How Hookdeck Can Help:** Hookdeck queues every delivery and can [limit the delivery rate](/docs/destinations#set-a-max-delivery-rate) to your service, so a backfill is spread out instead of overloading it.

### Two message shapes on one URL

**The Problem:** A handler written for notifications crashes on a read result, or the other way round.

**Why It Happens:** A webhook destination can receive both results and notifications. Results have `action` and `result`. Notifications have `notificationType` and `data`.

**Workarounds:**

* Branch on `notificationType` first, then on `action`.
* Or use separate destinations for data and notifications, each with its own handler.

**How Hookdeck Can Help:** Hookdeck filters on `notificationType` or `action` can route each kind of message to its own destination from a single source.

### Failing destinations get disabled

**The Problem:** After an outage, Ampersand stops sending to your webhook.

**Why It Happens:** Ampersand can disable a webhook destination automatically after repeated failed deliveries, and reports results it couldn't deliver with `destination.delivery.error`. It doesn't publish the retry schedule or the threshold.

**Workarounds:**

* Route `destination.webhook.disabled` and `destination.delivery.error` to a different destination, such as a Slack channel, so you hear about them when the webhook itself is down.
* Re-enable the destination and trigger reads for the gap.

**How Hookdeck Can Help:** Hookdeck accepts deliveries while your service is down, so Ampersand's attempts succeed. Events wait in Hookdeck and are delivered with [retries](/docs/retries) when your service recovers.

## Best practices

### Verify against the raw body

The signature covers the exact bytes. Use `express.raw({ type: '*/*' })` with a limit above 300 KB in Express, `await request.text()` in Next.js and `await request.body()` in FastAPI, and parse only after verification.

### Deduplicate on `svix-id`

Svix resends a message with the same `svix-id`. For subscribe results, the record ID with `subscribeEventType` is a useful second key. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Key everything by `groupRef`

Results and notifications both carry `groupRef` and `consumerRef`. Use them to find the right tenant before touching any data.

### Acknowledge fast and process asynchronously

Verify, store the message (and fetch any `downloadUrl`), return `200`, then process. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Ampersand delivers read and subscribe results and lifecycle notifications to webhook destinations through Svix, signed with `svix-*` headers and a per-destination `whsec_` secret. Verify against the raw body, branch on `notificationType` and `action`, fetch download URLs within 15 minutes, and plan for backfill bursts.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Ampersand signature, smooths out bursts, and keeps each message until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Ampersand webhooks reliably in minutes.