Developer documentation

Gifting API

Send branded merchandise from your stockroom with one API call. Ship to an address you know, or let the recipient choose their gift and enter their own. Follow every gift through signed webhooks.

API key auth
Inventory
Direct sends
Recipient choice
Status and tracking
Webhooks
HMAC signing

Quick start

Three steps to your first gift.

1

Get your API key

Go to Dashboard → Integrations and create an API key. Available on Gifting & Distribution and Business.

2

Put designs in a collection

Group the produced designs you want to send into a collection. For recipient choice, create a gift link on that collection that ships each gift when the recipient submits.

3

Create a send

POST /api/v1/sends with an address and design ids (DIRECT), or with a recipient email and your gift link (RECIPIENT_CHOICE). Brandmerch picks, packs and ships.

One send is one recipient: for a batch, make one call per recipient with an idempotency key each. There is no sandbox. To test, use a collection with a little test stock and an API key scoped to it; those sends create real packages in your warehouse dashboard, which you can cancel before they are picked.

Authentication

Send your API key in the X-API-Key header on every request. Keys start with bm_live_, belong to one company, and are created and revoked on Dashboard → Integrations.

bash
curl "https://api.brandmerch.com/api/v1/sends/inventory?collectionId=col_welcome_kits" \
  -H "X-API-Key: bm_live_your_key_here"

Stored hashed

Keys are stored as SHA-256 hashes and shown once.

Collection scoping

A scoped key sees only its collections' designs and gift links, and only the sends it created.

Revocable instantly

Revoke a key from the dashboard; it stops working at once.

Base URL

https://api.brandmerch.com

Endpoints

Select an endpoint to expand it. The full contract, every schema included, is in the OpenAPI 3.1 spec.

Statuses

Every send has one status, the same in API responses and in data.status of every event. A DIRECT send moves reserved → shipped → delivered. A recipient choice send starts awaiting_redemption and, once the recipient chooses, follows the same path.

StatusModeMeaning
awaiting_redemptionRecipient choiceThe invitation is live; the recipient has not chosen yet. No stock is reserved.
redeemedRecipient choiceThe recipient chose. Transient: the same step moves the send to reserved or held.
heldRecipient choiceA chosen size ran out between the choice and the reservation (holdReason out_of_stock). Retried hourly; cancelled after 14 days on hold.
reservedBothStock is reserved and the packages are queued, or being picked and packed.
shippedBothAt least one package has shipped.
deliveredBothEvery package is delivered (carrier tracking or a warehouse mark).
cancelledBothCancelled through the API, by Brandmerch, after a hold timed out, or by a privacy request. Final.
expiredRecipient choiceThe invitation expired unredeemed. A resend reopens it.
declinedRecipient choiceThe recipient declined the gift. Final.
createdBothRecorded but not yet reserved or invited. A send created through the API never rests here; an API order created before sends existed, with no packages, reads created.

Recipient choice sends

Invite a contact to choose their own gift and enter their own address. You send the invitation from your CRM; Brandmerch runs the page, reserves the choice and ships it.

The offer

offer.giftLinkId is a gift link of your company that is ready for API invitations. When one is missing, the API answers 409 offer_not_ready and reasons lists every gap:

RequirementReason when missing
Access by personal invitation links (magic links)access_mode_not_magic_link
Status Openstatus_not_open
A collection to choose fromno_collection
Ships each gift when the recipient submits (fulfillment mode ON_SUBMIT)fulfillment_mode_not_on_submit

A link where the recipient fills their own box must have a per-recipient item limit (Per package in the gift link builder): each pick ships at once, so the builder will not save such a link without one.

The redemption URL

  • redemptionUrl is the recipient's personal link to the gift page. It comes back on create, in the send.invited event, and from GET while the invitation is live.
  • Treat it as a secret: whoever holds it can choose the gift and enter an address. The gift's mail always goes to the invited email address.
  • You send it. Brandmerch does not send invitations: email it from your CRM, sequence or workflow. notify: true is refused.

What the recipient sees

  • Only the designs and sizes that are in stock right now. Stock counts are never shown.
  • What the gift link offers: a set gift (every product on the page, one option per product group, at the link's kit quantities) or a box they fill up to the link's per-recipient limit. Brandmerch checks the choice against the link when they submit.
  • A form for their address and choice. On submit the gift is reserved and queued to ship; they get a confirmation email with a Track Your Gift link to a status page.
  • If a size sells out while they choose, the page reloads with the sizes still in stock and asks them to choose again. Nothing is reserved until a choice goes through.
  • A No Thanks option. Declining closes the send (declined, send.declined) and cannot be undone.
  • If the invitation was cancelled, expired or already used, a clear message instead of the form.

Expiry, resend and holds

  • Expiry. The invitation is valid for expiresInDays (default: the gift link's invitation expiry, 30 days unless changed). Within the hour after expiresAt the send moves to expired and send.expired is recorded.
  • Resend. POST /api/v1/sends/{sendId}/resend issues a new link for an awaiting or expired send and records send.invited. The old link stops working. Up to 3 resends.
  • Held. If a chosen size runs out between the choice and the reservation, the send is held (send.held and send.exception with code out_of_stock). Brandmerch retries every hour and records send.reserved when stock returns. After 14 days on hold the send is cancelled with reason out_of_stock_timeout.
  • Cancel. Cancelling an awaiting send revokes its link; the recipient sees that the invitation is no longer active.

Until recipient choice is switched on, RECIPIENT_CHOICE answers 403 recipient_choice_unavailable.

Sender attribution

Every send is from one Brandmerch user: their name rides in the events and the order is requested by them. Name the sender with sender.userId or sender.email (userId wins when both are given).

  • A named sender must be an active member of your company who may send gifts: an owner, a manager, a direct creative, or a member an owner or manager granted Can Send Gifts on the Members page.
  • Name nobody and the send is attributed to your company default: the first active owner, then manager.
  • sender.externalId (up to 200 characters, for example your CRM user id) is stored and echoed back. It does not name a user.
  • Unknown keys in sender are refused, so a misspelt userID cannot fall back to the default silently.
StatusCodeWhen
403sender_not_memberThe named user is not an active member of your company (an unknown user answers the same).
403sender_not_permittedThe named member may not send gifts.
409no_default_senderNobody named and your company has no active owner or manager.
400invalid_sendersender is not { userId?, email?, externalId? } within the length limits.

Metadata and CRM correlation

Both blocks are stored on the send, returned by GET and list, and carried in data.crm and data.metadata of every event, so a webhook can update the right record without a lookup.

BlockRules
crmAny of provider, portalId, objectType, objectId, ownerId, dealId, workflowId, callbackId. Strings up to 200 characters (provider up to 32, stored lower-cased) or null. Other keys are refused (invalid_crm).
metadataA flat object of at most 50 keys. Keys: 1 to 40 letters, digits, underscore, dot or hyphen. Values: strings up to 500 characters, numbers, booleans or null (invalid_metadata).

Find sends by CRM record with GET /api/v1/sends?crmProvider=hubspot&crmObjectId=901. Keep recipient details out of metadata: it is erased with the recipient on a privacy request.

Idempotency

Pass an idempotencyKey (up to 200 characters, unique per company) on every create so a retry never sends twice.

  • The same key and the same body returns the original send with its current status: HTTP 200 and idempotent: true.
  • The same key with a different body is 422 idempotency_key_reused. The body is compared with keys sorted and idempotencyKey removed, so key order does not matter.
  • Two identical requests racing each other create one send; both get it back.
  • A collection-scoped key reusing another key's idempotencyKey also gets the 422.
  • On Gifting & Distribution the daily cap is checked first, so a retry at the cap answers 429.
json
// First request creates the send
POST /api/v1/sends { "idempotencyKey": "hubspot-901-q4-thanks", "mode": "RECIPIENT_CHOICE", ... }
// 201 { "sendId": "send_...", "status": "awaiting_redemption", "redemptionUrl": "...", ... }

// The same key and the same body returns the existing send and its current status
POST /api/v1/sends { "idempotencyKey": "hubspot-901-q4-thanks", "mode": "RECIPIENT_CHOICE", ... }
// 200 { "sendId": "send_...", "status": "shipped", "createdAt": "...", "idempotent": true, ... }

// The same key with a different body is refused
// 422 { "error": "This idempotencyKey was already used with a different request body.",
//       "code": "idempotency_key_reused" }

Errors

Every refusal is JSON with an error message and a stable code; only authentication refusals, the inventory endpoint and the daily send cap answer without a code. Match on code; messages can change. Some refusals add details next to it (required, stockIssues, unresolved, reasons, status, parameter). Messages never contain recipient details.

  • A body that is not valid JSON is 400 invalid_json on every route. It usually means a CRM value (a name with a double quote, an address with a line break) was pasted into a JSON template without escaping. Fix the body; retrying it cannot succeed.
  • A 500 with code internal_error is safe to retry. One kind of request is refused before it reaches the API, in another shape: a body larger than 50 MB, or in an encoding or charset the server does not accept, answers 500 { "status": "error", "code": 1000, "message": "Internal server error" }. Fix the request rather than retrying it.
StatusCodeMeaning
400invalid_jsonThe body is not valid JSON, for example a value with an unescaped double quote pasted into a JSON template. Any route, before authentication. Fix the body; retrying it cannot succeed.
400invalid_requestValid JSON that is not an object (an array), missing required fields (required lists them), offer or expiresInDays on a DIRECT body, a cancel reason that is not a string of at most 500 characters, or a resend body that is not an object.
400invalid_modemode is not DIRECT or RECIPIENT_CHOICE.
400notify_unsupportednotify: true. Brandmerch does not send invitations.
400invalid_recipientAn incomplete DIRECT address (required lists the fields) or a recipient field of the wrong type.
400invalid_recipient_emailrecipient.email is not an email address.
400invalid_countryrecipient.country is not ISO 3166-1 alpha-2 or a recognised country name.
400invalid_itemsA line without designId, a quantity that is not an integer from 1 to 1,000, or a blank size.
400too_many_itemsMore than 25 lines after combining the same designId and size.
400items_not_alloweditems on a RECIPIENT_CHOICE body.
400invalid_expires_in_daysexpiresInDays is not an integer from 1 to 365.
400invalid_gift_note / invalid_gift_boxgiftNote is not a string of at most 500 characters; giftBox is not a boolean.
400invalid_idempotency_keyidempotencyKey is not a string of at most 200 characters.
400invalid_metadata / invalid_crm / invalid_senderThe block breaks its rules (see Metadata and CRM correlation, Sender attribution).
400designs_not_callableA design cannot be sent; unresolved lists { designId, reason }.
400invalid_queryGET /api/v1/sends: parameter names the refused query parameter.
400invalid_emailprivacy-delete: the email is missing or not an address.
401(none)X-API-Key missing, not a bm_live_ key, unknown or revoked.
403(none)The account is deactivated, or its plan has no API access (upgrade_url).
403recipient_choice_unavailableRECIPIENT_CHOICE is not switched on yet.
403offer_out_of_key_scopeThe gift link is outside this key's collections.
403sender_not_memberThe named sender is not an active member of your company.
403sender_not_permittedThe named sender may not send gifts.
403scoped_key_not_allowedprivacy-delete: a collection-scoped key cannot file or read privacy requests. Use a key without collection scope.
404send_not_foundNo such send for this key.
404offer_not_foundNo such gift link in your company.
404erasure_not_foundNo such privacy request in your company.
409insufficient_inventoryNot enough stock; stockIssues lists { designId, size, requested, available }. Nothing was created.
409offer_not_readyThe gift link misses a precondition; reasons lists every one.
409no_default_senderNo sender named and your company has no active owner or manager.
409not_cancellableThe send is already shipped, delivered or closed (status).
409already_pickedA package is being picked or packed, or has shipped.
409cancel_incompleteA package could not be released; nothing was cancelled. Contact support.
409legacy_send_not_cancellableAn API order created before sends existed.
409not_recipient_choiceResend on a DIRECT send.
409not_resendableResend on a send that is not awaiting redemption or expired, or whose recipient was erased (status).
409resend_limit_reachedAlready resent the maximum number of times (resendCount, maxResends).
409status_changedThe send changed while you acted on it. Read it again; nothing was written.
422idempotency_key_reusedThe idempotencyKey was used with a different body (or by another key, for a collection-scoped key).
429(none)The Gifting & Distribution daily send cap. Wait for Retry-After.
429rate_limitedThe privacy-delete limit, or the per-IP failure limit on any route. Wait for Retry-After.
500internal_errorUnexpected failure. Safe to retry; with an idempotencyKey a create never runs twice.
503privacy_unavailablePrivacy requests cannot be accepted right now. Try again later.

Rate limits

  • Creating sends. Gifting & Distribution: 1,000 sends per rolling 24 hours per company, both modes. Business: unlimited. Creates carry X-RateLimit-Limit and X-RateLimit-Remaining; over the cap the answer is 429 with Retry-After, X-RateLimit-Reset and the same numbers in the body.
  • Privacy requests. 60 per hour per company, then 429 rate_limited with Retry-After.
  • Failures. Every route allows 1,000 failed (4xx or 5xx) responses per 15 minutes per IP address; successful requests do not count. Over it, every request from that address answers 429 rate_limited (with limit, reset and retryAfterSeconds) and Retry-After until the window resets.
  • Reads (inventory, list, get), cancel and resend are not metered otherwise.

Events

Subscribe on Dashboard → Integrations → Webhooks with an HTTPS URL and the events you want. The signing secret is shown once. Brandmerch posts each event as JSON to your URL, usually within a minute or two of the change.

EventSentWhen
send.createdYesEvery send, once. DIRECT: data.status reserved and fulfillmentRequests counts the packages. Recipient choice: data.status awaiting_redemption, fulfillmentRequests 0.
send.invitedYesRecipient choice: on create and on every resend. Carries redemptionUrl, expiresAt and resendCount.
send.viewedYesThe recipient opened the invitation, once per invitation (a resend re-arms it).
send.redeemedYesThe recipient chose. data.items is their choice.
send.declinedYesThe recipient declined the gift.
send.expiredYesThe invitation passed expiresAt unredeemed (checked hourly). Carries expiresAt.
send.heldYesThe choice could not be reserved; holdReason out_of_stock.
send.reservedYesStock is reserved: on a DIRECT create, at redemption, or when a held send is restocked.
send.shippedYesA package shipped. Once per package, with that package's tracking.
send.deliveredYesA package was delivered. Once per package; data.status becomes delivered with the last one.
send.exceptionYesSomething needs attention. Today: exception.code out_of_stock, recorded with send.held.
send.cancelledYesThe send was cancelled. data.reason is your cancel reason, order_cancelled, out_of_stock_timeout or privacy_erasure.
send.approvedReservedReserved for approvals. Subscribable, not sent yet.
send.deniedReservedReserved for approvals. Subscribable, not sent yet.
send.in_transitReservedReserved for carrier scans. Subscribable, not sent yet.
send.out_for_deliveryReservedReserved for carrier scans. Subscribable, not sent yet.

Per-package events

send.shipped and send.delivered fire once per package. A send with two packages records two of each; data.tracking and the top-level fulfillmentRequestId, trackingNumber and carrier name the package, and data.status is the send status after it. A voided label that ships again records a new send.shipped.

Ordering and duplicates

Delivery order is not guaranteed — a retried event can arrive after a later one. Order a send's events by timestamp (the time the event was recorded; events recorded together are 1 ms apart) and dedupe on X-BrandMerch-Event-Id. When two events of one send share a timestamp, read GET /api/v1/sends/{id} for the current status.

The event id is also in data.eventId, and it is the same on every retry. The data object is frozen when the event is recorded, so a retry carries identical bytes.

Payload

The envelope is { event, sendId, data, timestamp }. Every data carries eventId, sendId, orderId, companyId, mode, source, status, holdReason, recipient (email, name, company, city, state), sender, offer, items, crm, metadata and tracking (null unless the event is about one package); keys are present with null rather than left out. Events add their own keys: send.created fulfillmentRequests; send.invited redemptionUrl, expiresAt and resendCount; send.expired expiresAt; send.exception exception; send.shipped and send.delivered the package keys; send.cancelled and send.declined reason. After a privacy erasure, piiStripped: true marks a payload whose recipient details, metadata and redemptionUrl are null.

json
{
  "event": "send.shipped",
  "sendId": "send_3f2a9c1e-7b4d-4e0a-9c6b-2d8f1a5e7c3b",
  "data": {
    "eventId": "5b2e7c1a-9d4f-4e8b-a3c6-0f1e2d3c4b5a",
    "sendId": "send_3f2a9c1e-7b4d-4e0a-9c6b-2d8f1a5e7c3b",
    "orderId": "send_3f2a9c1e-7b4d-4e0a-9c6b-2d8f1a5e7c3b",
    "companyId": "c0a8012e-5f4b-4d3c-8a2b-1e9f8d7c6b5a",
    "mode": "DIRECT",
    "source": "API",
    "status": "shipped",
    "holdReason": null,
    "recipient": { "email": "jane@example.com", "name": "Jane Smith", "company": null,
                   "city": "San Francisco", "state": "CA" },
    "sender": { "userId": "usr_123", "email": "rep@example.com", "name": "Alex Rep",
                "externalId": null, "unmapped": false },
    "offer": null,
    "items": [ { "designId": "design_hoodie", "quantity": 1, "size": "M" } ],
    "crm": { "provider": "hubspot", "portalId": null, "objectType": "deal", "objectId": "777",
             "ownerId": null, "dealId": null, "workflowId": null, "callbackId": null },
    "metadata": null,
    "tracking": {
      "fulfillmentRequestId": "fr_001",
      "trackingNumber": "1Z999AA10123456784",
      "carrier": "UPS",
      "trackingUrl": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
      "shippedAt": "2026-10-12T14:30:00.000Z",
      "deliveredAt": null,
      "carrierTrackingStatus": "IN_TRANSIT"
    },
    "fulfillmentRequestId": "fr_001",
    "trackingNumber": "1Z999AA10123456784",
    "carrier": "UPS",
    "shippedAt": "2026-10-12T14:30:00.000Z"
  },
  "timestamp": "2026-10-12T14:31:20.000Z"
}

Headers

HeaderDescription
X-BrandMerch-SignatureHex HMAC-SHA256 of the raw request body, keyed with your subscription secret.
X-BrandMerch-EventThe event type, for example send.shipped.
X-BrandMerch-Event-IdThe event id (equals data.eventId). The same on every retry. Dedupe on it.
X-BrandMerch-Delivery-IdThis event to this subscription. The same on every retry.
X-BrandMerch-AttemptThe attempt number, 1 to 11.
Content-Typeapplication/json

Retries

Answer with a 2xx within 10 seconds and do the work afterwards. Any other status, a redirect, a timeout or a network error is retried 10 more times over about 24 hours (1, 2, 5, 15 and 30 minutes, then 1, 2, 4, 8 and 8 hours, each ±10%). A 410 stops retries for that delivery. Redirects are never followed.

Verifying signatures

Recompute the HMAC-SHA256 of the raw request body with your subscription secret and compare it with X-BrandMerch-Signature in constant time. Verify over the raw bytes: JSON.stringify(req.body) reproduces the body only while it is compact JSON in the same key order, so a parsed-and-reserialized body is not a safe input.

typescript
import crypto from 'crypto';
import express from 'express';

const app = express();

// express.raw keeps the body as the exact bytes Brandmerch signed.
app.post('/webhooks/brandmerch', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-BrandMerch-Signature') ?? '';
  const expected = crypto
    .createHmac('sha256', process.env.BRANDMERCH_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!valid) return res.status(401).send('Invalid signature');

  const eventId = req.get('X-BrandMerch-Event-Id');
  // Already processed eventId? Answer 200 and stop.
  const { event, sendId, data, timestamp } = JSON.parse(req.body.toString('utf8'));
  // Queue the work, then answer within 10 seconds.
  res.sendStatus(200);
});

When a secret is rotated, the old one stops signing at once: update your endpoint in the same step.

Recipient emails

You send invitations. Brandmerch sends the recipient's transactional shipping mail, in both modes.

ModeEmailsHow to turn them off
DIRECTYour package from {company} is on the way, once per new tracking number, with carrier tracking. Your package from {company} was delivered, once the whole send is delivered.Omit recipient.email on the create call.
RECIPIENT_CHOICEA confirmation when they submit and a shipped email, each linking to their gift status page, then Your gift was delivered.Not from the API or the dashboard today. Ask Brandmerch support to turn off shipping emails for the gift link; that stops the shipped and delivered emails for every send through it. The submit confirmation is still sent.

Shipping mail is skipped for events more than 72 hours old, for addresses on the Brandmerch suppression list, and for recipients erased on a privacy request.

Fees

API sends are billed like any other order from your stockroom.

  • Pick & pack: from the start date we announce to your account, each send created on or after it is billed once, when its first package ships: your account's pick & pack rate, a base fee per send plus a fee for each additional item type. Contract rates apply, and a contract that waives the fee books 0. Sends created before the start date are never charged pick & pack.
  • Gift add-ons: on those same sends, a gift note or gift box is billed at your account's add-on rate, once per send, together with the pick & pack.
  • Shipping: billed at carrier cost on your monthly statement, never per call. If you ship on your own carrier account, your carrier bills you directly.
  • GET /api/v1/sends/{sendId} and each send in GET /api/v1/sends show what was booked: fees.pickPackCents is the pick & pack plus gift add-ons (null until the first package ships, and for sends created before your start date; 0 when your contract waives it), and fees.shippingCents is the packages' shipping cost, counted once per shipping label: a package holding several items ships under one label and its cost is counted once, not once per item (null while no package has a cost).

Privacy

Erase a recipient on request with POST /api/v1/sends/privacy-delete.

  • Covers every send, order, package, gift link response and invitation of your company for that email address (matched case-insensitively), and the stored webhook payloads. Shipping labels and packing slips are deleted from storage.
  • Sends that can still be cancelled are cancelled first (send.cancelled with reason privacy_erasure).
  • Runs in the background within minutes. Poll GET /api/v1/sends/privacy-delete/{erasureId} until the status is completed, completed_with_exceptions or failed. A failed request did not finish after three attempts: contact support.
  • An exception is data that could not be erased on that run; exceptionsByReason counts them per reason. A package that is being picked or packed keeps its address so its label can print (in_fulfillment). Brandmerch then queues a follow-up request for the same address, with a new erasureId, that runs about 24 hours later and again every 24 hours until nothing is left in fulfillment, for up to 30 days after the original request. Filing the same address again returns the waiting follow-up (200, existing: true) and runs it at once.
  • Other exceptions, such as a label shared with another recipient's package (shared_label), are not re-run automatically. Contact support if one matters to you.
  • Needs an API key without collection scope: an erasure covers your whole company, including sends made with other keys, so a collection-scoped key gets 403 scoped_key_not_allowed on both routes.
  • Accepted even if your plan has lapsed. Limited to 60 requests per hour per company.
  • Copies already delivered to your systems, such as webhook payloads you stored, are yours to erase.

Recipes

Two ways to send a recipient choice gift from a CRM without running your own service. Both create the same send; your CRM sends the redemption URL.

Zapier

  1. Trigger: New deal stage in your CRM (or any CRM trigger).
  2. Action: Webhooks by Zapier, POST. URL https://api.brandmerch.com/api/v1/sends, Payload Type json, Unflatten yes, and a header X-API-Key with your key. Enter Data as one row per field, naming nested fields with a double underscore, as in the table below. Zapier builds the nested JSON from those names and escapes every mapped value. A key written with a dot, such as offer.giftLinkId, is sent as a flat key and answers 400 invalid_request.
  3. Action: Update record. Write redemptionUrl, status and sendId from the response to the contact.
  4. Your CRM's own email or sequence sends the redemption URL to the contact. Brandmerch does not send it.
Data keyValue
modeRECIPIENT_CHOICE
offer__giftLinkIdYour ON_SUBMIT gift link id
recipient__emailThe contact's email
recipient__nameThe contact's full name
sender__emailThe deal owner's email
crm__providerhubspot (or your CRM)
crm__objectTypecontact
crm__objectIdThe contact id
crm__ownerIdThe deal owner id
crm__dealIdThe deal id
idempotencyKeyhubspot-<contact id>-<campaign>

The idempotency key makes a re-run Zap return the same send instead of a second gift. Webhooks by Zapier's Custom Request action also works, with the JSON below as its raw Data and a Content-Type: application/json header, but it pastes mapped values in as they are: a name with a double quote or a line break then makes the body invalid JSON (400 invalid_json). Prefer the POST action.

json
{
  "mode": "RECIPIENT_CHOICE",
  "offer": { "giftLinkId": "<your ON_SUBMIT gift link id>" },
  "recipient": { "email": "<contact email>", "name": "<contact full name>" },
  "sender": { "email": "<deal owner email>" },
  "crm": {
    "provider": "hubspot",
    "objectType": "contact",
    "objectId": "<contact id>",
    "ownerId": "<deal owner id>",
    "dealId": "<deal id>"
  },
  "idempotencyKey": "hubspot-<contact id>-<campaign>"
}

Status back to the CRM (optional second Zap)

  1. Trigger: Webhooks by Zapier, Catch Raw Hook. Subscribe its URL on Dashboard → Integrations → Webhooks to send.redeemed, send.shipped and send.delivered. Catch Raw Hook keeps the body as the exact bytes Brandmerch signed, and the request headers; the plain Catch Hook parses the body and cannot be verified.
  2. Action: Code by Zapier, Run JavaScript, with the code below. Map the raw body, the X-BrandMerch-Signature header and your subscription secret into its Input Data. A body that does not match the signature stops the run.
  3. Action: update the record found by crmObjectId with status. Events can arrive out of order, so keep the newest: store timestamp with the status and skip an event older than the one stored.
javascript
// Code by Zapier, Run JavaScript. Input Data:
//   rawBody   = the Catch Raw Hook's raw body
//   signature = its X-BrandMerch-Signature header
//   secret    = your webhook subscription secret
const crypto = require('crypto');
const expected = crypto
  .createHmac('sha256', inputData.secret)
  .update(inputData.rawBody, 'utf8')
  .digest('hex');
const given = (inputData.signature || '').trim();
if (
  given.length !== expected.length ||
  !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
) {
  throw new Error('Invalid Brandmerch signature'); // stops this run
}
const { event, sendId, data, timestamp } = JSON.parse(inputData.rawBody);
output = {
  event,
  sendId,
  timestamp,
  status: data.status,
  crmObjectId: data.crm ? data.crm.objectId : null,
};

What this check does and does not do: the secret sits in the Zap, so anyone who can edit the Zap can read it. Zapier answers every hook with 200 at once, so a delivery the code rejects is not retried. Zap history keeps each payload, recipient name and email included. If you would rather not keep the secret in Zapier, use the plain Catch Hook, treat each hook only as a prompt, and read the send with GET /api/v1/sends/{sendId} (authenticated with your API key) before writing its status.

Salesforce Flow

A Record-Triggered Flow calls a small Apex action that posts the send. External Services cannot import this API's spec as published (it is OpenAPI 3.1, and it is served from brandmerch.com rather than from the API host), and an Apex action builds the JSON with JSON.serialize, so names with quotes or line breaks are always escaped.

  1. Setup → Named Credentials. Create an External Credential (authentication protocol Custom) with a principal that holds your API key as an authentication parameter, and a custom header X-API-Key whose value is that parameter. Create a Named Credential called Brandmerch for https://api.brandmerch.com that uses it, and give the users the Flow runs as access to the principal through a permission set.
  2. Add the Apex class below (Setup → Apex Classes) with a test class for deployment.
  3. Build a Record-Triggered Flow on Opportunity, run after the record is saved, when the stage changes to the one you choose. Add a Run Asynchronously path: a callout cannot run in the record's own save transaction.
  4. On that path, Get Records: the OpportunityContactRole where OpportunityId is {!$Record.Id} and IsPrimary is true. Then Get Records: the Contact whose Id is that role's ContactId.
  5. Action: Send Brandmerch gift. giftLinkId your ON_SUBMIT gift link id; contactId, email and name from the Contact; senderEmail {!$Record.Owner.Email}; ownerId {!$Record.OwnerId}; opportunityId {!$Record.Id}; idempotencyKey a text formula such as "salesforce-" & {!Get_Contact.Id} & "-q4-thanks".
  6. Update Records: the Contact's Brandmerch_Redemption_URL__c and Brandmerch_Gift_Status__c from the action's redemptionUrl and status. When errorCode is set, nothing was sent: route it to whoever owns the integration.
  7. An Email Alert or sales sequence sends the URL to the contact.
  8. Status write-back: subscribe a webhook to the Apex REST endpoint below, exposed through a Salesforce Site (the Site's guest user needs access to the class), or poll GET /api/v1/sends?crmProvider=salesforce&crmObjectId=… from a scheduled Apex job through the same Named Credential.

Apex action (posts the send)

apex
public with sharing class BrandmerchSendGift {
  public class Request {
    @InvocableVariable(required=true) public String giftLinkId;
    @InvocableVariable(required=true) public String contactId;
    @InvocableVariable(required=true) public String email;
    @InvocableVariable public String name;
    @InvocableVariable public String senderEmail;
    @InvocableVariable public String ownerId;
    @InvocableVariable public String opportunityId;
    @InvocableVariable(required=true) public String idempotencyKey;
  }

  public class Result {
    @InvocableVariable public String sendId;
    @InvocableVariable public String status;
    @InvocableVariable public String redemptionUrl;
    @InvocableVariable public String errorCode;
  }

  @InvocableMethod(label='Send Brandmerch gift' callout=true)
  public static List<Result> send(List<Request> requests) {
    List<Result> results = new List<Result>();
    for (Request r : requests) {
      Map<String, Object> body = new Map<String, Object>{
        'mode' => 'RECIPIENT_CHOICE',
        'offer' => new Map<String, Object>{ 'giftLinkId' => r.giftLinkId },
        'recipient' => new Map<String, Object>{ 'email' => r.email, 'name' => r.name },
        'crm' => new Map<String, Object>{
          'provider' => 'salesforce',
          'objectType' => 'Contact',
          'objectId' => r.contactId,
          'ownerId' => r.ownerId,
          'dealId' => r.opportunityId
        },
        'idempotencyKey' => r.idempotencyKey
      };
      if (String.isNotBlank(r.senderEmail)) {
        body.put('sender', new Map<String, Object>{ 'email' => r.senderEmail });
      }
      HttpRequest req = new HttpRequest();
      req.setEndpoint('callout:Brandmerch/api/v1/sends'); // the Named Credential adds X-API-Key
      req.setMethod('POST');
      req.setHeader('Content-Type', 'application/json');
      req.setBody(JSON.serialize(body)); // escapes quotes and line breaks in names
      req.setTimeout(30000);
      HttpResponse res = new Http().send(req);

      Result out = new Result();
      Map<String, Object> parsed = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
      if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
        out.sendId = (String) parsed.get('sendId');
        out.status = (String) parsed.get('status');
        out.redemptionUrl = (String) parsed.get('redemptionUrl');
      } else {
        Object code = parsed.get('code');
        out.errorCode = code != null ? String.valueOf(code) : String.valueOf(res.getStatusCode());
      }
      results.add(out);
    }
    return results;
  }
}

Apex REST endpoint (webhook write-back)

apex
@RestResource(urlMapping='/brandmerch/webhook')
global without sharing class BrandmerchWebhook {
  @HttpPost
  global static void receive() {
    RestRequest req = RestContext.request;
    Blob body = req.requestBody; // the raw bytes Brandmerch signed
    String secret = Brandmerch_Settings__c.getOrgDefaults().Webhook_Secret__c;
    String expected = EncodingUtil.convertToHex(
      Crypto.generateMac('hmacSHA256', body, Blob.valueOf(secret)));
    String given = '';
    for (String header : req.headers.keySet()) {
      if (header.equalsIgnoreCase('X-BrandMerch-Signature')) given = req.headers.get(header);
    }
    if (!expected.equals(given)) {
      RestContext.response.statusCode = 401;
      return;
    }
    Map<String, Object> envelope = (Map<String, Object>) JSON.deserializeUntyped(body.toString());
    Map<String, Object> data = (Map<String, Object>) envelope.get('data');
    Map<String, Object> crm = (Map<String, Object>) data.get('crm');
    if (crm != null && 'salesforce'.equals(crm.get('provider')) && crm.get('objectId') != null) {
      try {
        Id contactId = Id.valueOf((String) crm.get('objectId'));
        // allOrNone false: a deleted Contact must not turn into a 500 that is retried.
        Database.update(new Contact(
          Id = contactId,
          Brandmerch_Gift_Status__c = (String) data.get('status')), false);
      } catch (StringException notAnId) {
        // Not a Salesforce id: nothing to update.
      }
    }
    RestContext.response.statusCode = 200;
  }
}

The endpoint answers 401 to a body whose signature does not match, and 200 otherwise, so a delivery for a Contact that no longer exists is not retried. Events can arrive out of order: to keep the newest status, also store the envelope's timestamp and skip an older event.

Changelog

October 9, 2026

Recipient choice, one status vocabulary, durable webhooks

Status values

BeforeNowNotes
reservedreservedUnchanged.
partially_shippedshippedA send is shipped once any package ships. Per-package events are unchanged.
shippedshipped
delivereddeliveredEvery package delivered (carrier or warehouse mark).
pending_fulfillment (idempotent replay echoed the order status)reservedA replay now returns the send status.
pending_fulfillment or another order status while packages are picked or packed (GET and replay)reserved
Every package cancelledcancelled
(none)created, awaiting_redemption, redeemed, held, expired, declinedNew, for recipient choice.

Also new or changed

  • New: RECIPIENT_CHOICE sends, GET /api/v1/sends (list), POST /api/v1/sends/{sendId}/cancel, POST /api/v1/sends/{sendId}/resend, and POST and GET /api/v1/sends/privacy-delete.
  • New events: send.invited, send.viewed, send.redeemed, send.declined, send.expired, send.held, send.reserved, send.exception and send.cancelled, plus four reserved types.
  • Every event's data now carries eventId, status, recipient, sender, offer, items, crm, metadata and tracking. The existing send.created, send.shipped and send.delivered keys are unchanged.
  • Three new webhook headers: X-BrandMerch-Event-Id, X-BrandMerch-Delivery-Id and X-BrandMerch-Attempt.
  • timestamp is now when the event was recorded, and the same on every retry. Delivery order is not guaranteed.
  • Deliveries now make up to 11 attempts over about 24 hours (previously 3 within a minute); a 410 stops retries.
  • Refusals carry a machine-readable code (authentication, inventory and the daily send cap excepted).
  • A body that is not valid JSON is now 400 invalid_json (previously a 500), and the per-IP failure limit answers JSON 429 rate_limited (previously plain text).
  • An idempotencyKey reused with a different body is now 422 idempotency_key_reused, as is a collection-scoped key reusing another key's idempotencyKey.
  • recipient.country must be ISO 3166-1 alpha-2; common country names are normalized.
  • At most 25 lines per send and 1,000 units per line.
  • notify: true is refused (notify_unsupported).
  • GET /api/v1/sends/{sendId} no longer returns orders that were not created through the API.
  • recipient.email on a DIRECT send now receives Brandmerch shipped and delivered tracking emails. Omit it to send none.
  • privacy-delete is limited to 60 requests per hour per company (429 rate_limited), and needs an API key without collection scope (403 scoped_key_not_allowed).
  • The OpenAPI 3.1 spec is published at /openapi.json.

Ready to integrate?

Create an API key and send your first gift today.