Guide to Goody Webhooks: Features and Best Practices
Goody lets companies send gifts to employees, customers and prospects without collecting addresses first: the recipient gets a link, opens it, can swap the gift for something else, and accepts it before anything ships. Its Automation API and Commerce API let you send gifts from code, and its webhooks tell you where each gift is in that journey, from the moment the batch is created to the thank-you note at the end.
This guide covers Goody's order and order-batch events, how batch size and scheduling change when orders appear, setting up webhooks in each environment, verifying the Svix signature, and two inconsistencies in Goody's docs worth coding around.
What are Goody webhooks?
Goody sends webhooks for orders (one gift to one recipient) and order batches (a send to one or more recipients). Webhooks cover every order in the organisation, which is why only organisation admins can create them. Production and sandbox are separate Goody environments with separate webhooks and secrets.
Delivery runs on Svix. Goody's verification page shows the svix library with the svix-id, svix-timestamp and svix-signature headers and a whsec_ secret, and links Svix's guide for verifying by hand. Each endpoint has a delivery log in Goody, and failed deliveries can be re-sent from there.
Goody webhook features
| Feature | Details |
|---|---|
| Configuration | Organization > Automation API (or the Commerce API app's page) |
| Who can configure | Organisation admins |
| Environments | Production and sandbox, configured separately |
| Subscription model | Message Filtering: blank for all events, or chosen events |
| Delivery platform | Svix |
| Headers | svix-id, svix-timestamp, svix-signature |
| Signature | Standard Webhooks HMAC-SHA256, v1,<base64> |
| Secret | Signing Secret on the webhook's configuration page (whsec_...) |
| Body | {event_type, id, data} |
| Event routing | Body field event_type |
| Delivery logs | Per endpoint, with manual re-send of failures |
| Retries, timeout | Not documented by Goody |
| Source IPs | Not published |
Events and payloads
| Event | Triggered When | Common Use Cases |
|---|---|---|
order_batch.created | An order batch is created (it may still be sending, or scheduled for later) | Track batch progress |
order_batch.completed | An order batch is fully processed and all orders exist | Collect the gift links |
order.created | An order is created and ready to be opened and accepted | Send individual_gift_link to the recipient |
order.gift_opened | A gift is first opened (opening the link yourself can trigger it) | Engagement tracking |
order.gift_accepted | The recipient accepts the gift; final costs are calculated | Record amounts, is_swapped, cart |
order.thank_ | The recipient adds a thank you note | Show the note (thank_you_note) |
order.shipped | The order ships (first shipment in transit) | Notify the sender |
order.delivered | The order is delivered (all shipments delivered) | Close out the order |
order.canceled | The order is cancelled | Reverse bookkeeping |
order.refunded | The order is refunded | Reverse bookkeeping |
Goody documents these 10 and doesn't call the list complete. Every body has event_type, an event id, and data, which is the order, or the batch for order_batch.*. Goody's schema names the event ID event_id, but every example carries it as id, so read both.
Here is part of the order.canceled sample from the webhook samples library:
{
"event_type": "order.canceled",
"id": "ad097c47-bf12-45c9-8feb-0536a0012c09",
"data": {
"id": "8999f804-d731-440d-a67d-35444c12c493",
"status": "failed",
"individual_gift_link": "https://gifts.ongoody.com/gift/IsDyQQWtqrOyGPgZ9bVm6ptz",
"recipient_first_name": "Alena",
"recipient_email": "alena@ongoody.com",
"thank_you_note": null,
"is_swapped": false,
"order_batch_id": "d158184c-6b31-4a72-b39f-8f8ca04ecbdd",
"cart": { "id": "3ff7dab1-ae03-4b20-a2c8-82a3f3326b83", "items": [] },
"shipments": [],
"amounts": {
"amount_product": 1000,
"amount_shipping": 1000,
"amount_processing_fee": 100,
"amount_pre_tax_total": 2100,
"amount_tax": null,
"amount_total": null
},
"workspace_id": "dfe0d5cb-2c98-4800-a950-b40cca509250",
"reference_id": "2MUPN0YMGIDT7TJNPPFPB7WE"
}
}
In this example amount_tax and amount_total are still null. Goody says final costs are calculated when the gift is accepted, so order.gift_accepted is the event to read them from, together with is_swapped and the cart the recipient swapped to.
When batch orders appear
- For a non-scheduled batch of 10 or fewer recipients,
order_batch.createdalready contains every order. - For a larger batch, orders are created asynchronously, and
order_batch.completedarrives once they exist. - For a scheduled batch, no orders exist until the scheduled send time.
order_batch.completedarrives then, along with the orders.
So a handler that collects gift links should wait for order_batch.completed, or rely on each order.created, rather than reading them from order_batch.created.
Setting up Goody webhooks
- Sign in as an organisation admin. Click Organization at the top right, then Automation API. The Commerce API app has its own webhook page under the same menu. Direct links:
- Production:
https://www.ongoody.com/plus/organization/api/automation - Sandbox:
https://sandbox.ongoody.com/plus/organization/api/automation
- Production:
- Add a webhook and enter your HTTPS URL in Endpoint URL, for example
https://example.com/webhooks/goody. - Under Message Filtering, pick events or leave it blank for all of them. A typical gifting integration starts with
order.created,order.gift_accepted,order.shipped,order.deliveredandorder.canceled. - Open the webhook's configuration page and copy the Signing Secret (
whsec_...) intoGOODY_WEBHOOK_SECRET. - Repeat in the other environment. A sandbox secret won't verify production deliveries.
The sandbox is the place to test. While building, the Hookdeck CLI can forward sandbox deliveries to your local server:
npx hookdeck-cli listen 3000 goody --path /webhooks/goody
With Hookdeck. In Hookdeck, create a Goody source, enter the webhook's whsec_ signing secret, and set the source URL as Goody's endpoint URL. The source accepts POST only. Goody receives a 200 from Hookdeck whether or not the signature checks out; a failure is recorded as failed verification in Hookdeck and the request isn't forwarded.
Securing Goody webhooks
Goody's webhook security page asks you to verify against the raw body with the signing secret. Following Svix's construction, the signed content is {svix-id}.{svix-timestamp}.{raw body}, the key is the base64-decoded secret after whsec_, and svix-signature may hold several v1, entries. This verifier implements that and also reads webhook-* headers if svix-* are missing:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['svix-id'] ? 'svix' : 'webhook';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Goody has the batch-name inconsistency described below, so the handler accepts both spellings:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
const app = express();
const BATCH_COMPLETED = new Set(['order_batch.completed', 'gift_batch.completed']);
app.post('/webhooks/goody', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.GOODY_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const payload = parseJsonObject(req.body);
if (!payload) return res.sendStatus(400); // signed, but not a JSON object
const { event_type: eventType, data } = payload;
if (typeof eventType !== 'string' || !data || typeof data !== 'object') return res.sendStatus(400);
try {
if (eventType === 'order.created') {
await outbox.sendGiftLink(data.recipient_email, data.individual_gift_link);
} else if (eventType === 'order.gift_accepted') {
await spend.record(data.id, data.amounts, data.is_swapped);
} else if (BATCH_COMPLETED.has(eventType)) {
await batches.markComplete(data.id);
}
} catch {
return res.sendStatus(503); // not processed, so don't acknowledge
}
return res.sendStatus(200);
});
outbox, spend and batches are placeholders for your own services.
The svix library in Goody's examples rejects a svix-timestamp more than 5 minutes from the current time. Hookdeck doesn't apply a timestamp window, and any retry or replay from Hookdeck carries Goody's original svix-timestamp, so behind Hookdeck you'd want the signature check without that age limit, as above.
Make Goody webhooks production-ready. Hookdeck Event Gateway checks Goody's Svix signatures, keeps every order event, and retries delivery to your service on a schedule you choose.
Goody webhook limitations and pain points
Batch events may use an older name
The Problem: A router keyed on order_batch.completed never sees batch completions.
Why It Happens: Goody's event list and page titles use order_batch.created and order_batch.completed, but both example payloads carry gift_batch.created and gift_batch.completed. Which one is sent hasn't been confirmed.
Workarounds:
- Accept both spellings, as the handler above does.
- Log the raw
event_typeof the first batch events you receive.
How Hookdeck Can Help: Hookdeck filters can match either name, and the request log shows which one Goody actually sent.
event_id or id
The Problem: Deduplication code reads a field that's never there.
Why It Happens: The event schemas call the field event_id, while the examples use id.
Workarounds:
- Read
id ?? event_id, or use thesvix-idheader, which stays fixed across retries and re-sends.
How Hookdeck Can Help: Hookdeck's deduplication can key on the svix-id header, which avoids the body field question.
No published retry policy
The Problem: You can't size an outage budget for Goody events.
Why It Happens: Goody documents delivery logs and manual re-send, but no retry schedule, timeout or disabling rule. Svix's defaults probably apply; Goody doesn't say.
Workarounds:
- Acknowledge quickly and process in a worker.
- After downtime, check the delivery log and re-send what failed.
How Hookdeck Can Help: Hookdeck accepts each Goody event straight away and handles retries to your service with a policy you set.
order.gift_opened can be triggered by you
The Problem: Engagement numbers show gifts as opened by recipients who never saw them.
Why It Happens: Goody notes that opening the gift link yourself, for example to check it, can fire order.gift_opened.
Workarounds:
- Don't open recipients' links from staff accounts in production, or treat the first open as unconfirmed.
How Hookdeck Can Help: Hookdeck's event history makes it easy to see when an open arrived relative to when you sent the link.
Best practices
Wait for the right batch event
Use order_batch.completed, or each order.created, to collect gift links. order_batch.created only includes orders for small, non-scheduled batches.
Record costs on acceptance
Final amounts, tax and swaps are only known at order.gift_accepted. Book spend there, not at order.created.
Keep sandbox and production apart
Store the two secrets under different names and route the two environments to different endpoints, so test gifts never reach production systems.
Verify the raw bytes
Use express.raw({ type: '*/*' }), await request.text() in a Next.js route handler, or await request.body() in FastAPI, and turn off CSRF protection for the route. Return 200 for event types you don't handle. See our guide to webhook idempotency.
Conclusion
Goody's webhooks track a gift's whole life, and the order data is detailed enough to drive notifications, spend tracking and CRM updates. Verify with the Svix secret for the right environment, accept both batch event names, deduplicate on svix-id, and book costs when the gift is accepted rather than when it's sent.
Hookdeck Event Gateway gives Goody's events a durable landing point with replay and retries you control, which matters when Goody's own retry policy isn't documented. Create a Hookdeck account to try it in your sandbox.