# Webhooks


:::note[Two distinct webhook systems]
This guide covers **Distribution Partner async events** (`order_create` / `order_update`) — fire-and-forget notifications signed with a custom HMAC scheme.

If you are a **Commerce Partner** implementing synchronous validate/place order webhooks, see [Commerce API — Order Webhooks](/commerce-partners/order-webhooks) instead. That system uses the Standard Webhooks spec with different headers and a different signing algorithm.
:::

When Gett sends async order event requests to your system, every request includes signature headers for verification and follows a retry policy for reliability.

## Webhook Events

Gett emits two event types over the order lifecycle. Both are delivered as `POST` requests to the webhook URL you register during integration setup.

| Event type | When it fires | Timing |
|---|---|---|
| `order_create` | After a successful `placeOrder` — the Gett order has been accepted | Async — may arrive after the API response returns |
| `order_update` | When the order status changes (e.g., confirmed, shipped, fulfilled, canceled) | Async |

### `order_create` payload

```json
{
  "type": "order_create",
  "data": {
    "type": "order",
    "checkout_session_id": "csn_01hvz9kj0re0000000000000",
    "permalink_url": "https://gett.com/orders/018f4d2a-c5b0-7c4e-9b3a-2d1e8f7a6b5c",
    "status": "created",
    "refunds": []
  }
}
```

### `order_update` payload

```json
{
  "type": "order_update",
  "data": {
    "type": "order",
    "checkout_session_id": "csn_01hvz9kj0re0000000000000",
    "permalink_url": "https://gett.com/orders/018f4d2a-c5b0-7c4e-9b3a-2d1e8f7a6b5c",
    "status": "confirmed",
    "refunds": []
  }
}
```

**`status` values**: `created`, `manual_review`, `confirmed`, `canceled`, `shipped`, `fulfilled`.

**`refunds[]` shape** (present when refunds have been applied):

```json
{
  "type": "store_credit",
  "amount": 1050
}
```

`type` is `store_credit` or `original_payment`. `amount` is in minor units (cents for USD — e.g. `1050` = $10.50).

---

## Webhook Signatures

Every webhook request includes headers for signature verification:

| Header | Value |
|---|---|
| `Signature` | `base64( HMAC-SHA256( secret-as-UTF-8-bytes, "{Timestamp}.{rawBody}" ) )` |
| `Timestamp` | RFC 3339 / ISO 8601 instant — e.g. `2026-06-17T17:34:56.1234567+00:00` |
| `API-Version` | Protocol version: `2026-01-30` for ACP, `2026-03-01` for UCP. The value in the header is authoritative for that request. |

The signed message is `{Timestamp}.{rawBody}` — the RFC 3339 timestamp string, a literal `.`, then the raw request body bytes. The secret is used **as-is as raw UTF-8 bytes** — it is **not** base64-decoded (this differs from the Standard Webhooks scheme used by the Commerce API). The resulting HMAC is **base64-encoded** (not hex). Use the `Timestamp` header to reject replays: discard any request whose timestamp is more than 300 seconds from the current time.

### Verification Examples

**TypeScript / Node.js**

```typescript
import crypto from 'crypto';

function verifyWebhookSignature(
  rawBody: string,
  signature: string,
  timestamp: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  // Replay protection: parse RFC 3339 timestamp and reject if too old
  const ts = Date.parse(timestamp);
  if (isNaN(ts) || Math.abs(Date.now() - ts) / 1000 > toleranceSeconds) return false;

  // Signed content is "{timestamp}.{rawBody}" — timestamp IS prepended
  const expected = crypto
    .createHmac('sha256', secret)           // secret used as raw UTF-8 string bytes
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('base64');                      // base64, NOT hex

  return crypto.timingSafeEqual(
    Buffer.from(signature, 'base64'),
    Buffer.from(expected, 'base64'),
  );
}

// In your webhook handler:
app.post('/webhook/order', (req, res) => {
  const signature = req.headers['signature'] as string;
  const timestamp = req.headers['timestamp'] as string;
  if (!verifyWebhookSignature(req.rawBody, signature, timestamp, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  // Process the webhook...
});
```

**Python**

```python
import base64
import hashlib
import hmac
from datetime import datetime, timezone

def verify_webhook_signature(
    raw_body: bytes,
    signature: str,
    timestamp: str,
    secret: str,
    tolerance_seconds: int = 300,
) -> bool:
    # Replay protection: parse RFC 3339 timestamp and reject if too old
    ts = datetime.fromisoformat(timestamp)
    age = abs((datetime.now(timezone.utc) - ts).total_seconds())
    if age > tolerance_seconds:
        return False

    # Signed content is "{timestamp}.{rawBody}" — timestamp IS prepended
    # Secret is used as raw UTF-8 bytes — do NOT base64-decode it
    signed = timestamp.encode() + b"." + raw_body
    expected = base64.b64encode(
        hmac.new(secret.encode(), signed, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(signature, expected)
```

:::warning[Always Verify]
Never process a webhook without verifying the signature. Reject requests with missing, invalid, or stale signatures with a `401` response.
:::

---

## 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 doesn't respond within 30 seconds.

---

## Idempotency

Your webhook endpoints **must be idempotent**. Use the `orderId` as an idempotency key — if you receive a duplicate request, return `200 OK` with the existing result rather than creating a duplicate.

---

## Best Practices

- Return `200 OK` as quickly as possible; process work asynchronously
- Store the `orderId` before returning the response
- Log all webhook payloads for debugging
- If you return a non-2xx response, the same webhook will be retried
