Skip to main content
Version: 2026-05

Webhooks

&Open sends webhook events to notify your application when something happens in your account. Each delivery is signed, so you can verify it came from &Open and was not tampered with in transit.

Every event describes one gift request. There are no campaign or user events, so a delivery always tells you that a single gift request has moved.

Setting up an endpoint

An admin on your team creates the endpoint from the Webhooks page in the admin area of your &Open account. The account needs to be on the Enterprise tier. Creating an endpoint sets four things:

  • the URL &Open posts to
  • a description, so you can tell your endpoints apart
  • the payload version, reported back on every delivery in AndOpen-Webhook-API-Version
  • the events this endpoint receives

&Open generates the signing secret at the same time. Store it where your handler can read it, and rotate it from the same page if it leaks.

The payload version decides the body format. An endpoint on 2026-05 receives the flat JSON object described in The payload, with Content-Type: application/json. Endpoints on earlier versions keep receiving a JSON:API document with Content-Type: application/vnd.api+json. Both see the same events.

Several endpoints can subscribe to the same event. Each one gets its own delivery, signature, and retry budget. Disabling an endpoint stops its deliveries at the source: no event is recorded for it while it's disabled, so re-enabling it delivers nothing from the gap.

Events

EventFires whenWatch out for
gift_request_createdThe gift request is created, including through POST /gift_requests.Fires once per gift request, so a batch of 50 sends 50 deliveries. Also fires for pregenerated one-link gift requests, which are placeholders waiting for a gift recipient to claim them.
gift_request_redeemedThe gift recipient finishes their part, either choosing a gift or having one ordered for them.Arrives alongside gift_request_delivered, in no guaranteed order, when a gift request goes straight from awaiting the gift recipient to complete.
gift_request_dispatchedThe parcel enters transit.Fires more than once if a parcel goes out for delivery and then returns to transit. Read it as "in transit now" each time it arrives.
gift_request_deliveredThe gift arrives.Fires at most once per gift request. A gift with several lines can re-enter the delivered state as failed lines are retried, and only the first entry sends an event.
gift_request_cancelledThe gift request is cancelled, expires, or has its shipment cancelled.Three outcomes share one name. "Cancelled" reads as a deliberate act, but an expiry and a cancelled shipment emit the same event.

Most internal state changes emit nothing. &Open tracks 17 states internally and exposes five through the API. Backorders, shipping errors, and failed delivery attempts produce no webhook, so webhooks alone won't tell you a gift has stalled.

The payload

The body is the gift request itself: a flat JSON object with no envelope, byte-identical to what Get a gift request returns. Anything you already parse from that endpoint parses here unchanged.

FieldDescription
status (string · required)The public-facing status, one of submitted, redeemed, dispatched, delivered, cancelled. It tracks the event, so a gift_request_dispatched delivery carries status: "dispatched". Branch your handler on this.
reference (string · required)The human-readable handle for the gift request, stable for its lifetime. Log it: it's the handle &Open support asks you for.
campaign (object · required · nullable)An embedded summary of the campaign, and null for an ad-hoc send that belongs to no campaign.
redemption_url (string · uri · required · nullable)The link the gift recipient follows to redeem. null on a gift_request_created delivery, because the link is generated shortly after creation.
shipping_address (object · required · nullable)The address the gift ships to. Present in direct-send mode, null in choice mode.
line_items (array · required · nullable)What the gift recipient is getting. null, never [], until the items lock: at creation for direct-send, and at redemption for choice mode.
offering (object · required)What was put in front of the gift recipient. Read it while line_items is still null.
{
"id": "4fb4cb3f-9666-43b5-8884-7f5194483d1a",
"reference": "ACME-M5RD6J8",
"status": "redeemed",
"campaign": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Q4 Customer Appreciation",
"web_url": "https://acme.andopen.co/sender/gift-history/campaigns/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"redemption_url": "https://gift.andopen.co/r/abc123",
"web_url": "https://acme.andopen.co/sender/gift-history/4fb4cb3f-9666-43b5-8884-7f5194483d1a",
"recipient": {
"id": "b2c3d4e5-f6a7-8901-bcde-f01234567890",
"thank_you_count": 1,
"first_name": "Alice",
"last_name": "Smith",
"name": "Alice Smith",
"email": "alice@example.com"
},
"sender": {
"id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"name": "Dana Okafor",
"email": "dana@acme.example"
},
"shipping_address": null,
"offering": {
"selection_mode": "one",
"visible_at_redemption": true,
"line_items": [
{
"id": "f6a7b8c9-d0e1-2345-6789-0abcdef01234",
"sku": "NOTEBOOK-A5",
"variant_id": "a7b8c9d0-e1f2-3456-789a-bcdef0123456",
"name": "Leather Notebook",
"quantity": 1,
"images": [
{
"name": "leather-notebook.jpg",
"url": "https://files.andopen.co/rndd63mwgj94j2vf3v2ngnye0hkn",
"urls": {
"thumbnail": "https://files.andopen.co/vw2mvwevyqxh98l7esf67nvunr4h",
"large": "https://files.andopen.co/mz4rc8kx1nvq7j93wtf5bd2hs0yl",
"large_wide": "https://files.andopen.co/q7pz3n8htv2ke0d5rjw94flxs6cb"
}
}
]
}
]
},
"line_items": [
{
"id": "d4e5f6a7-b8c9-0123-4567-890abcdef012",
"sku": "HOODIE-L-BLK",
"variant_id": "c3d4e5f6-a7b8-9012-cdef-012345678901",
"name": "Hoodie: Large / Black",
"quantity": 1,
"images": [
{
"name": "hoodie-black.jpg",
"url": "https://files.andopen.co/8k2mfp4qz7x9v1c3b5n0jhtl",
"urls": {
"thumbnail": "https://files.andopen.co/p3wq7m2zx8k4n6v0bjhtldsf",
"large": "https://files.andopen.co/r9td5c1yfw3jq7n2vk8mzbxh",
"large_wide": "https://files.andopen.co/h4nj8sv2qd6kz0wt3mfy9lpc"
}
}
]
}
],
"email_message": {
"subject": "A small thank-you",
"body": "Thanks for being a great customer!"
},
"created_at": "2026-04-16T10:30:00.000Z",
"updated_at": "2026-04-16T10:30:00.000Z"
}

The payload is a snapshot taken when the event fired, not when it was delivered. &Open serializes the gift request once and stores it on the event, so a delivery that succeeds on its fourth attempt still carries the body built for the first attempt. Treat a late arrival as history. When you need current state, re-fetch with Get a gift request.

Headers

Every webhook request includes these headers:

HeaderDescription
AndOpen-Webhook-SignatureHMAC-SHA256 signature of the request, hex-encoded
AndOpen-Webhook-Dispatch-TimestampUnix time, in seconds, when the request was dispatched
AndOpen-Webhook-Event-IdUUID of the event: a stable handle for deduplication and log correlation
AndOpen-Webhook-Event-TypeThe type of event being delivered
AndOpen-Webhook-API-VersionThe API version the payload is serialized in
Content-Typeapplication/json

Verifying signatures

Verify every delivery before you act on it:

  1. Read the timestamp from AndOpen-Webhook-Dispatch-Timestamp.
  2. Concatenate the timestamp and the raw request body with a dot: "{timestamp}.{body}". Use the bytes you received; re-serializing the parsed JSON can change the body and break the signature.
  3. Compute the HMAC-SHA256 of that string with your webhook secret.
  4. Compare the result to AndOpen-Webhook-Signature with a constant-time comparison.

Replay protection and duplicates

The dispatch timestamp gives you stateless replay protection: it stops an attacker who captures a delivery from re-sending it to your endpoint later. After the signature checks out, confirm the timestamp is within an acceptable window of the current time (five minutes is a reasonable default) and reject anything older. Because a valid request stops validating once the window passes, a captured one is useless to a replay attacker.

The timestamp is set fresh on each delivery attempt, so legitimate retries are not rejected as stale.

Delivery is at-least-once, so expect duplicates. A delivery that times out after your handler has already committed its work is retried, and your handler runs again on the same event. Deduplicate on AndOpen-Webhook-Event-Id: record the IDs you have processed and ignore any you have already seen. The same ID is sent on every retry of a given event.

Responding to a delivery

Return any 2xx status to accept a delivery. &Open treats every other status, and any connection failure, as a failed attempt.

Nothing reads your response body, so an empty 200 is the whole contract. Send back a 403 when a signature or staleness check fails, as the examples below do, and a 5xx when your own processing is broken and you want the delivery retried.

&Open doesn't follow redirects. A 301 or 302 counts as a failed attempt, so point the endpoint URL at the address that serves the request.

Acknowledge within 15 seconds and do your work asynchronously. &Open allows 5 seconds to open the connection and 15 seconds for your response, and a handler that does its processing before responding is the usual reason for hitting that ceiling.

&Open makes up to 5 attempts in total, with increasing delays between them. After the fifth the event is abandoned and never retried, so a handler that stays down long enough loses events permanently. Every attempt reuses the same AndOpen-Webhook-Event-Id and carries a fresh dispatch timestamp, so a legitimate retry passes the staleness check in Replay protection and duplicates.

Ordering is not guaranteed. Each event is delivered by an independent background job retrying on its own schedule, so gift_request_dispatched can arrive before gift_request_redeemed. Drive your state from the status on the payload and the event type, both of which hold whenever the delivery lands.

Code examples

These examples verify the signature and reject stale requests. Send back a 403 when either check fails.

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

const app = express();
const secret = process.env.ANDOPEN_WEBHOOK_SECRET;

// Mount the raw body parser so `req.body` is the exact bytes &Open sent.
// Verifying against a re-serialized JSON parse would break the signature.
app.post('/webhooks', express.raw({ type: '*/*' }), (req, res) => {
const timestamp = req.headers['andopen-webhook-dispatch-timestamp'];
const signature = req.headers['andopen-webhook-signature'];
const body = req.body; // Buffer of the raw request body

// Reject stale requests (replay protection)
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(403).send('Timestamp too old');
}

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

const expectedBuf = Buffer.from(expected);
const signatureBuf = Buffer.from(signature);
if (expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)) {
return res.status(403).send('Invalid signature');
}

// Signature is valid. Acknowledge now and handle the event asynchronously,
// so the response lands inside the 15-second window.
res.status(200).send();
});