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. For a one-link gift request, when a gift recipient claims it.A batch of 50 sends 50 deliveries. A one-link batch sends nothing at first: you get one delivery per claim, as people claim them. The same gift request can send this twice, because an abandoned claim frees the link for the next person. Treat a repeat as an update to what you already have.
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. A direct send never sends this one. If you supplied the address yourself, there's no redemption.
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. You can also get one for a gift request you've not seen in a webhook previously, if a one-link gift request is cancelled or expires before anyone claims it.

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 usually matches the event, so a gift_request_dispatched delivery carries status: "dispatched". Two don't match. A created delivery carries submitted, or redeemed for a direct send. A redeemed delivery carries delivered when the gift completes in one step.
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)The link the gift recipient follows to redeem. Always set, including on a gift_request_created delivery.
shipping_address (object · required · nullable)The address the gift ships to. Present as soon as there's an address to show: from creation for a direct send, and from redemption once the gift recipient enters one. null before that, and for gifts that don't ship, such as digital or cause gifts.
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.
cancellable (boolean · required)Whether the gift request could be cancelled when the event fired. true only on a submitted gift request, so a gift_request_cancelled delivery always carries false.
invitation_method (string · required)How the send reaches the gift recipient, one of email_invitation, direct_message_invitation, sender_provided_address. With direct_message_invitation, &Open sends no invitation, so a gift_request_created delivery is your cue to pass redemption_url on.
invitation_subject (string · required · nullable)The invitation email's subject line, or null when the send carries no invitation email.
notecard_message (string · required · nullable)The note packed with a Marketplace physical gift, or null when the send carries none.
lifecycle_events (object · required)When each step of the gift request happened, as of the event. A gift_request_redeemed delivery carries redeemed_at, for example. See Get a gift request for what each timestamp records.
Response · 200
{
"id": "4fb4cb3f-9666-43b5-8884-7f5194483d1a",
"reference": "ACME-M5RD6J8",
"status": "redeemed",
"cancellable": false,
"invitation_method": "email_invitation",
"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!"
},
"invitation_subject": "A small thank-you",
"notecard_message": null,
"lifecycle_events": {
"invitation_email_sent_at": "2026-04-16T10:31:00.000Z",
"invitation_first_opened_at": "2026-04-16T18:40:00.000Z",
"invitation_last_opened_at": "2026-04-17T08:12:00.000Z",
"reminder_sent_at": null,
"redeemed_at": "2026-04-18T14:05:00.000Z",
"dispatched_at": null,
"delivered_at": null
},
"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();
});