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.
Quick start
Three steps to your first gift.
Get your API key
Go to Dashboard → Integrations and create an API key. Available on Gifting & Distribution and Business.
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.
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.
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.comEndpoints
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.
| Status | Mode | Meaning |
|---|---|---|
awaiting_redemption | Recipient choice | The invitation is live; the recipient has not chosen yet. No stock is reserved. |
redeemed | Recipient choice | The recipient chose. Transient: the same step moves the send to reserved or held. |
held | Recipient choice | A chosen size ran out between the choice and the reservation (holdReason out_of_stock). Retried hourly; cancelled after 14 days on hold. |
reserved | Both | Stock is reserved and the packages are queued, or being picked and packed. |
shipped | Both | At least one package has shipped. |
delivered | Both | Every package is delivered (carrier tracking or a warehouse mark). |
cancelled | Both | Cancelled through the API, by Brandmerch, after a hold timed out, or by a privacy request. Final. |
expired | Recipient choice | The invitation expired unredeemed. A resend reopens it. |
declined | Recipient choice | The recipient declined the gift. Final. |
created | Both | Recorded 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:
| Requirement | Reason when missing |
|---|---|
| Access by personal invitation links (magic links) | access_mode_not_magic_link |
| Status Open | status_not_open |
| A collection to choose from | no_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
redemptionUrlis 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: trueis 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 afterexpiresAtthe send moves to expired and send.expired is recorded. - Resend.
POST /api/v1/sends/{sendId}/resendissues 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.
| Status | Code | When |
|---|---|---|
| 403 | sender_not_member | The named user is not an active member of your company (an unknown user answers the same). |
| 403 | sender_not_permitted | The named member may not send gifts. |
| 409 | no_default_sender | Nobody named and your company has no active owner or manager. |
| 400 | invalid_sender | sender 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.
| Block | Rules |
|---|---|
crm | Any 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). |
metadata | A 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.
// 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_jsonon 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_erroris 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.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The 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. |
| 400 | invalid_request | Valid 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. |
| 400 | invalid_mode | mode is not DIRECT or RECIPIENT_CHOICE. |
| 400 | notify_unsupported | notify: true. Brandmerch does not send invitations. |
| 400 | invalid_recipient | An incomplete DIRECT address (required lists the fields) or a recipient field of the wrong type. |
| 400 | invalid_recipient_email | recipient.email is not an email address. |
| 400 | invalid_country | recipient.country is not ISO 3166-1 alpha-2 or a recognised country name. |
| 400 | invalid_items | A line without designId, a quantity that is not an integer from 1 to 1,000, or a blank size. |
| 400 | too_many_items | More than 25 lines after combining the same designId and size. |
| 400 | items_not_allowed | items on a RECIPIENT_CHOICE body. |
| 400 | invalid_expires_in_days | expiresInDays is not an integer from 1 to 365. |
| 400 | invalid_gift_note / invalid_gift_box | giftNote is not a string of at most 500 characters; giftBox is not a boolean. |
| 400 | invalid_idempotency_key | idempotencyKey is not a string of at most 200 characters. |
| 400 | invalid_metadata / invalid_crm / invalid_sender | The block breaks its rules (see Metadata and CRM correlation, Sender attribution). |
| 400 | designs_not_callable | A design cannot be sent; unresolved lists { designId, reason }. |
| 400 | invalid_query | GET /api/v1/sends: parameter names the refused query parameter. |
| 400 | invalid_email | privacy-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). |
| 403 | recipient_choice_unavailable | RECIPIENT_CHOICE is not switched on yet. |
| 403 | offer_out_of_key_scope | The gift link is outside this key's collections. |
| 403 | sender_not_member | The named sender is not an active member of your company. |
| 403 | sender_not_permitted | The named sender may not send gifts. |
| 403 | scoped_key_not_allowed | privacy-delete: a collection-scoped key cannot file or read privacy requests. Use a key without collection scope. |
| 404 | send_not_found | No such send for this key. |
| 404 | offer_not_found | No such gift link in your company. |
| 404 | erasure_not_found | No such privacy request in your company. |
| 409 | insufficient_inventory | Not enough stock; stockIssues lists { designId, size, requested, available }. Nothing was created. |
| 409 | offer_not_ready | The gift link misses a precondition; reasons lists every one. |
| 409 | no_default_sender | No sender named and your company has no active owner or manager. |
| 409 | not_cancellable | The send is already shipped, delivered or closed (status). |
| 409 | already_picked | A package is being picked or packed, or has shipped. |
| 409 | cancel_incomplete | A package could not be released; nothing was cancelled. Contact support. |
| 409 | legacy_send_not_cancellable | An API order created before sends existed. |
| 409 | not_recipient_choice | Resend on a DIRECT send. |
| 409 | not_resendable | Resend on a send that is not awaiting redemption or expired, or whose recipient was erased (status). |
| 409 | resend_limit_reached | Already resent the maximum number of times (resendCount, maxResends). |
| 409 | status_changed | The send changed while you acted on it. Read it again; nothing was written. |
| 422 | idempotency_key_reused | The 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. |
| 429 | rate_limited | The privacy-delete limit, or the per-IP failure limit on any route. Wait for Retry-After. |
| 500 | internal_error | Unexpected failure. Safe to retry; with an idempotencyKey a create never runs twice. |
| 503 | privacy_unavailable | Privacy 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-LimitandX-RateLimit-Remaining; over the cap the answer is 429 withRetry-After,X-RateLimit-Resetand the same numbers in the body. - Privacy requests. 60 per hour per company, then 429
rate_limitedwithRetry-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(withlimit,resetandretryAfterSeconds) andRetry-Afteruntil 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.
| Event | Sent | When |
|---|---|---|
send.created | Yes | Every send, once. DIRECT: data.status reserved and fulfillmentRequests counts the packages. Recipient choice: data.status awaiting_redemption, fulfillmentRequests 0. |
send.invited | Yes | Recipient choice: on create and on every resend. Carries redemptionUrl, expiresAt and resendCount. |
send.viewed | Yes | The recipient opened the invitation, once per invitation (a resend re-arms it). |
send.redeemed | Yes | The recipient chose. data.items is their choice. |
send.declined | Yes | The recipient declined the gift. |
send.expired | Yes | The invitation passed expiresAt unredeemed (checked hourly). Carries expiresAt. |
send.held | Yes | The choice could not be reserved; holdReason out_of_stock. |
send.reserved | Yes | Stock is reserved: on a DIRECT create, at redemption, or when a held send is restocked. |
send.shipped | Yes | A package shipped. Once per package, with that package's tracking. |
send.delivered | Yes | A package was delivered. Once per package; data.status becomes delivered with the last one. |
send.exception | Yes | Something needs attention. Today: exception.code out_of_stock, recorded with send.held. |
send.cancelled | Yes | The send was cancelled. data.reason is your cancel reason, order_cancelled, out_of_stock_timeout or privacy_erasure. |
send.approved | Reserved | Reserved for approvals. Subscribable, not sent yet. |
send.denied | Reserved | Reserved for approvals. Subscribable, not sent yet. |
send.in_transit | Reserved | Reserved for carrier scans. Subscribable, not sent yet. |
send.out_for_delivery | Reserved | Reserved 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.
{
"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
| Header | Description |
|---|---|
X-BrandMerch-Signature | Hex HMAC-SHA256 of the raw request body, keyed with your subscription secret. |
X-BrandMerch-Event | The event type, for example send.shipped. |
X-BrandMerch-Event-Id | The event id (equals data.eventId). The same on every retry. Dedupe on it. |
X-BrandMerch-Delivery-Id | This event to this subscription. The same on every retry. |
X-BrandMerch-Attempt | The attempt number, 1 to 11. |
Content-Type | application/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.
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.
| Mode | Emails | How to turn them off |
|---|---|---|
| DIRECT | Your 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_CHOICE | A 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
- Trigger: New deal stage in your CRM (or any CRM trigger).
- Action: Webhooks by Zapier, POST. URL
https://api.brandmerch.com/api/v1/sends, Payload Typejson, Unflattenyes, and a headerX-API-Keywith 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 asoffer.giftLinkId, is sent as a flat key and answers 400invalid_request. - Action: Update record. Write
redemptionUrl,statusandsendIdfrom the response to the contact. - Your CRM's own email or sequence sends the redemption URL to the contact. Brandmerch does not send it.
| Data key | Value |
|---|---|
mode | RECIPIENT_CHOICE |
offer__giftLinkId | Your ON_SUBMIT gift link id |
recipient__email | The contact's email |
recipient__name | The contact's full name |
sender__email | The deal owner's email |
crm__provider | hubspot (or your CRM) |
crm__objectType | contact |
crm__objectId | The contact id |
crm__ownerId | The deal owner id |
crm__dealId | The deal id |
idempotencyKey | hubspot-<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.
{
"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)
- 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.
- Action: Code by Zapier, Run JavaScript, with the code below. Map the raw body, the
X-BrandMerch-Signatureheader and your subscription secret into its Input Data. A body that does not match the signature stops the run. - Action: update the record found by
crmObjectIdwithstatus. Events can arrive out of order, so keep the newest: storetimestampwith the status and skip an event older than the one stored.
// 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.
- 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-Keywhose value is that parameter. Create a Named Credential calledBrandmerchforhttps://api.brandmerch.comthat uses it, and give the users the Flow runs as access to the principal through a permission set. - Add the Apex class below (Setup → Apex Classes) with a test class for deployment.
- 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.
- 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. - 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". - Update Records: the Contact's
Brandmerch_Redemption_URL__candBrandmerch_Gift_Status__cfrom the action's redemptionUrl and status. When errorCode is set, nothing was sent: route it to whoever owns the integration. - An Email Alert or sales sequence sends the URL to the contact.
- 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)
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)
@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
| Before | Now | Notes |
|---|---|---|
| reserved | reserved | Unchanged. |
| partially_shipped | shipped | A send is shipped once any package ships. Per-package events are unchanged. |
| shipped | shipped | |
| delivered | delivered | Every package delivered (carrier or warehouse mark). |
| pending_fulfillment (idempotent replay echoed the order status) | reserved | A replay now returns the send status. |
| pending_fulfillment or another order status while packages are picked or packed (GET and replay) | reserved | |
| Every package cancelled | cancelled | |
| (none) | created, awaiting_redemption, redeemed, held, expired, declined | New, 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.