# Order Webhooks

Gett drives the order lifecycle by POSTing synchronously to two endpoints on your system. You respond with the order body (including your server-computed amounts); Gett waits for your reply before continuing. These are **not** fire-and-forget events — every delivery is a blocking RPC.

:::note
These Commerce API webhooks are a separate system from the [Distribution Partner async event webhooks](/distribution-partners/shared-guides/webhooks). They use a different signing scheme (Standard Webhooks) and a different interaction model (synchronous request/response vs. fire-and-forget).
:::

---

## Endpoints

Gett POSTs to `{baseUrl}/validate` and `{baseUrl}/place`, where `baseUrl` is the **Webhook Base URL** you provide during onboarding.

| Path | Purpose | When called |
|------|---------|-------------|
| `{baseUrl}/validate` | Price and availability check | Before the customer pays |
| `{baseUrl}/place` | Place the order | After successful payment |

Both endpoints receive the same order body shape (see [Request body](#request-body)). The `/place` body additionally carries `paymentToken` when Gett tokenized the card on the partner's behalf (see [Payment token](#payment-token)).

---

## Request body

The request body is the order represented as a `StandardOrderWebhook` object — an `ExternalOrder` extended with an optional `paymentToken`. Field-level schemas live in the Commerce API Reference: [**ExternalOrder**](/api/commerce/~schemas#externalorder) (the `/validate` body) and [**StandardOrderWebhook**](/api/commerce/~schemas#standardorderwebhook) (the `/place` body). The `validateOrder` / `placeOrder` summaries are also listed under **Webhooks** on the [reference overview](/api/commerce).

### Validate — example request body

All IDs are UUIDs. Monetary `amounts` are decimal major units (e.g. `24.00` = $24.00) — **not** minor units. Every `lineItem` carries a `modifierGroups` array (empty when the item has no modifiers).

```json
{
  "gettOrderId": "018f4d2a-c5b0-7c4e-9b3a-2d1e8f7a6b5c",
  "cart": {
    "storeId": "7c3a1e90-2b4d-4f8a-9c1e-5a6b7c8d9e0f",
    "catalogSetId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
    "lineItems": [
      {
        "itemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "quantity": 2,
        "modifierGroups": [
          {
            "modifierGroupId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
            "lineItems": [
              {
                "itemId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
                "quantity": 1,
                "modifierGroups": []
              }
            ]
          }
        ]
      }
    ]
  },
  "amounts": {
    "currency": "USD",
    "subTotal": "24.00",
    "tip": "3.00"
  },
  "fulfillmentType": "PICKUP",
  "customer": {
    "firstName": "Alex",
    "lastName": "Kim",
    "email": "alex@example.com",
    "phone": "+14155552671"
  }
}
```

### Place — example request body

Identical to validate, plus `paymentToken` when Gett performed Braintree tokenization:

```json
{
  "gettOrderId": "018f4d2a-c5b0-7c4e-9b3a-2d1e8f7a6b5c",
  "cart": { "...": "same as validate" },
  "amounts": { "currency": "USD", "subTotal": "24.00", "tip": "3.00" },
  "fulfillmentType": "PICKUP",
  "customer": { "...": "same as validate" },
  "paymentToken": "tokencc_bf_k8x2m9_single_use_nonce"
}
```

#### Payment token

`paymentToken` is a **Braintree single-use payment-method nonce**. It is present on `/place` only when your store has a Braintree tokenization key configured — Gett tokenizes the customer's saved card against your Braintree account and sends you the nonce to charge. When absent, payment falls back to your own tokenization flow.

:::warning[Treat the payment token as a secret]
The nonce is a short-lived, single-use payment credential — usable for up to a few hours until it is consumed. Use it once over TLS to charge, then discard it. Gett masks `paymentToken` in its own logs and distributed traces, and you must do the same: **never write the raw value to logs, traces, or analytics.** If you need a reference for reconciliation, keep only non-sensitive descriptors (e.g. card type or last four), never the nonce itself.
:::

---

## Response body

Respond with the same order shape, filling in the server-authoritative `amounts` and any `errors`. HTTP `200` with a valid body is the success signal; any non-`2xx` triggers Gett's retry policy.

### Amounts ownership

| Field | Owner | Rule |
|-------|-------|------|
| `subTotal` | Gett | Echo verbatim |
| `tip` | Gett | Echo verbatim |
| `fees` | Partner | Set authoritatively; Gett stores your value |
| `taxes` | Partner | Set authoritatively; Gett stores your value |

### Example success response

```json
{
  "gettOrderId": "018f4d2a-c5b0-7c4e-9b3a-2d1e8f7a6b5c",
  "partnerOrderId": "POS-99012",
  "cart": { "...": "echo the cart" },
  "amounts": {
    "currency": "USD",
    "subTotal": "24.00",
    "tip": "3.00",
    "fees": "1.50",
    "taxes": "1.98",
    "total": "30.48"
  },
  "fulfillmentType": "PICKUP",
  "errors": []
}
```

### Example error response (still HTTP 200)

Return errors inline on `200` — do **not** use `4xx` for business-logic rejections (item unavailable, store closed, etc.). Reserve non-`2xx` for infrastructure failures.

```json
{
  "gettOrderId": "018f4d2a-c5b0-7c4e-9b3a-2d1e8f7a6b5c",
  "cart": { "...": "echo the cart" },
  "amounts": null,
  "fulfillmentType": "PICKUP",
  "errors": [
    {
      "code": "ITEM_UNAVAILABLE",
      "message": "This item is not available at this time.",
      "itemId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "jsonPath": "$.cart.lineItems[0]"
    }
  ]
}
```

---

## Security — Standard Webhooks signing

Gett signs every outbound webhook per the open [**Standard Webhooks**](https://www.standardwebhooks.com) spec. Because the spec is open, you can verify signatures using **off-the-shelf libraries** (e.g. [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) on npm, [`standardwebhooks`](https://pypi.org/project/standardwebhooks/) on PyPI) rather than writing verification code by hand.

### Headers Gett sends

| Header | Value |
|--------|-------|
| `Authorization` | `Bearer <apiKey>` — authenticates Gett to your endpoint |
| `Idempotency-Key` | Stable GUID for this delivery (reused across retries; use it for dedup) |
| `webhook-id` | Stable message ID (same value as `Idempotency-Key` for this delivery) |
| `webhook-timestamp` | Unix timestamp in **seconds** of this delivery attempt |
| `webhook-signature` | Space-delimited list of `v1,<base64>` signatures (see below) |

### Signature algorithm

The signed content is:

```
{webhook-id}.{webhook-timestamp}.{rawRequestBody}
```

For each active signing secret, Gett computes:

```
v1,base64( HMAC-SHA256( key, signedContent ) )
```

where `key` is the **base64-decoded** bytes of the part after `whsec_` in your secret (e.g. if your secret is `whsec_ABC123==`, the HMAC key is `base64Decode("ABC123==")`).

The `webhook-signature` header contains one `v1,<base64>` entry per active secret, **space-delimited**. This enables zero-downtime secret rotation: during a rotation overlap window both the old and new secret produce a signature; your verifier accepts whichever one it currently holds.

### Verifying with a library (recommended)

```typescript
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(signingSecret); // signingSecret = your whsec_... value

app.post('/validate', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = wh.verify(req.body, {
    'webhook-id': req.headers['webhook-id'] as string,
    'webhook-timestamp': req.headers['webhook-timestamp'] as string,
    'webhook-signature': req.headers['webhook-signature'] as string,
  });
  // payload is the parsed order; process and respond
  res.json({ ...payload, amounts: { ...payload.amounts, fees: 150, taxes: 198 } });
});
```

### Verifying manually (TypeScript / Node.js)

```typescript
import crypto from 'crypto';

function verifyStandardWebhook(
  rawBody: Buffer,
  webhookId: string,
  webhookTimestamp: string,
  webhookSignature: string,
  signingSecret: string,       // your whsec_... value
  toleranceSeconds = 300,
): boolean {
  // Replay protection
  const ts = parseInt(webhookTimestamp, 10);
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;

  // Decode the HMAC key: strip 'whsec_' prefix, then base64-decode
  const prefix = 'whsec_';
  const b64 = signingSecret.startsWith(prefix)
    ? signingSecret.slice(prefix.length)
    : signingSecret;
  const key = Buffer.from(b64, 'base64');

  const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', key)
    .update(signedContent)
    .digest('base64');

  // webhook-signature may contain multiple space-delimited v1,<base64> entries
  return webhookSignature
    .split(' ')
    .some(entry => {
      const sig = entry.startsWith('v1,') ? entry.slice(3) : entry;
      try {
        return crypto.timingSafeEqual(
          Buffer.from(sig, 'base64'),
          Buffer.from(expected, 'base64'),
        );
      } catch {
        return false;
      }
    });
}
```

### Verifying manually (Python)

```python
import base64
import hashlib
import hmac
import time

def verify_standard_webhook(
    raw_body: bytes,
    webhook_id: str,
    webhook_timestamp: str,
    webhook_signature: str,
    signing_secret: str,         # your whsec_... value
    tolerance_seconds: int = 300,
) -> bool:
    # Replay protection
    ts = int(webhook_timestamp)
    if abs(time.time() - ts) > tolerance_seconds:
        return False

    # Decode the HMAC key: strip 'whsec_' prefix, then base64-decode
    prefix = "whsec_"
    b64 = signing_secret[len(prefix):] if signing_secret.startswith(prefix) else signing_secret
    key = base64.b64decode(b64)

    signed_content = f"{webhook_id}.{webhook_timestamp}.".encode() + raw_body
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode()

    # webhook-signature may contain multiple space-delimited v1,<base64> entries
    for entry in webhook_signature.split(" "):
        sig = entry[3:] if entry.startswith("v1,") else entry
        if hmac.compare_digest(sig, expected):
            return True
    return False
```

:::warning[Always verify signatures]
Never process an order webhook without verifying the signature and checking the timestamp. Reject requests with missing, invalid, or stale signatures with a `401` response.
:::

---

## Idempotency

Gett retries failed deliveries. Use `Idempotency-Key` (or `webhook-id` — they carry the same value) to deduplicate: if you have already processed this key, return `200` with the previously computed result rather than placing the order again.

---

## Retry policy

Gett retries failed webhook deliveries with exponential backoff:

| Attempt | Delay |
|---------|-------|
| 1st retry | 30 seconds |
| 2nd retry | 2 minutes |
| 3rd retry | 10 minutes |
| 4th retry | 1 hour |
| Final retry | 6 hours |

A delivery is considered failed if your endpoint returns a non-`2xx` status code or does not respond within 30 seconds.

---

## Best practices

- Respond as quickly as possible; the timeout is 30 seconds
- Always verify the signature before reading the body
- Use `Idempotency-Key` / `webhook-id` to deduplicate retried deliveries
- Return inline `errors` on HTTP `200` for business-logic rejections; reserve non-`2xx` for infrastructure failures
- Log the full request including all headers for debugging
