Gareth Wilson Gareth Wilson

Guide to Ampersand Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard (Destinations) or POST /v1/projects/{project}/destinations
Endpoint URLMust start with https
Data routingPer object, in amp.yaml (destination: <name>)
Notification routingEvent types to topics, topics to destinations
Message kindsResults (action) and notifications (notificationType)
Signature headerssvix-id, svix-timestamp, svix-signature
SchemeSvix (Standard Webhooks): HMAC-SHA256, base64, entries prefixed with v1,
Signed content{svix-id}.{svix-timestamp}.{raw body}
SecretWebhook Signing Secret (whsec_...), one per destination, rotatable
Payload sizeAt most 300 KB. Larger records arrive as a download URL that expires after 15 minutes
ThroughputAt most 1000 messages per second across all destinations by default
Auto-disableA webhook destination can be disabled after repeated failed deliveries
Retries and source IPsNot 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:

{
  "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": { ... } }:

AreaNotification types
Installationsinstallation.created, installation.updated, installation.deleted
Readsread.backfill.done, read.backfill.error, read.schedule.paused, read.triggered.done, read.triggered.error
Writeswrite.async.done
Subscriptionssubscribe.create.success, subscribe.update.success, subscribe.delete.success, subscribe.create.error, subscribe.update.error, subscribe.delete.error
Connectionsconnection.created, connection.refreshed, connection.updated, connection.deleted, connection.error
Destinationsdestination.webhook.disabled, destination.delivery.error
{
  "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:

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:

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.

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:

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 includes a manual verifier where you can drop or widen the window.

Make Ampersand webhooks production-ready. Hookdeck 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 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 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 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.

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.

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 verifies every Ampersand signature, smooths out bursts, and keeps each message until your service has processed it.

Get started with Hookdeck for free and handle Ampersand webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.