Gareth Wilson Gareth Wilson

Guide to Goody Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationOrganization > Automation API (or the Commerce API app's page)
Who can configureOrganisation admins
EnvironmentsProduction and sandbox, configured separately
Subscription modelMessage Filtering: blank for all events, or chosen events
Delivery platformSvix
Headerssvix-id, svix-timestamp, svix-signature
SignatureStandard Webhooks HMAC-SHA256, v1,<base64>
SecretSigning Secret on the webhook's configuration page (whsec_...)
Body{event_type, id, data}
Event routingBody field event_type
Delivery logsPer endpoint, with manual re-send of failures
Retries, timeoutNot documented by Goody
Source IPsNot published

Events and payloads

EventTriggered WhenCommon Use Cases
order_batch.createdAn order batch is created (it may still be sending, or scheduled for later)Track batch progress
order_batch.completedAn order batch is fully processed and all orders existCollect the gift links
order.createdAn order is created and ready to be opened and acceptedSend individual_gift_link to the recipient
order.gift_openedA gift is first opened (opening the link yourself can trigger it)Engagement tracking
order.gift_acceptedThe recipient accepts the gift; final costs are calculatedRecord amounts, is_swapped, cart
order.thank_you_note_addedThe recipient adds a thank you noteShow the note (thank_you_note)
order.shippedThe order ships (first shipment in transit)Notify the sender
order.deliveredThe order is delivered (all shipments delivered)Close out the order
order.canceledThe order is cancelledReverse bookkeeping
order.refundedThe order is refundedReverse 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.created already contains every order.
  • For a larger batch, orders are created asynchronously, and order_batch.completed arrives once they exist.
  • For a scheduled batch, no orders exist until the scheduled send time. order_batch.completed arrives 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

  1. 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
  2. Add a webhook and enter your HTTPS URL in Endpoint URL, for example https://example.com/webhooks/goody.
  3. 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.delivered and order.canceled.
  4. Open the webhook's configuration page and copy the Signing Secret (whsec_...) into GOODY_WEBHOOK_SECRET.
  5. 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_type of 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 the svix-id header, 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.