# Guide to Goody Webhooks: Features and Best Practices

[Goody](https://www.ongoody.com) 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_you_note_added` | 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/goody/latest/order.canceled.json):

```json
{
  "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](/docs/cli) can forward sandbox deliveries to your local server:

```bash
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](https://developer.ongoody.com/webhooks/webhook-security) 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:

```javascript
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:

```javascript
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](/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](/docs/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](/docs/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](/docs/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](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) to try it in your sandbox.