# Guide to Gopuff Webhooks: Features and Best Practices

Gopuff webhooks keep partner merchants informed about orders that Gopuff fulfils for them: when an order is packed, out for delivery, delivered or cancelled, where the driver is right now, and when the order is expected to arrive. If you sell through the Powered by Gopuff partner API, webhooks are how those updates reach your order system and your customers without polling.

This guide covers how Gopuff webhook endpoints are registered, how deliveries are signed, the payloads you will receive, and the best practices for running a Gopuff receiver in production. Gopuff's own webhook documentation is short, so where it is silent this guide says so.

## What are Gopuff webhooks?

Gopuff runs its partner webhooks on Svix. You do not register endpoints through a Gopuff API call. Instead, you ask Gopuff for a time-limited link to a Svix App Portal, and in that portal you add endpoints, choose event types, look at delivery history and replay past events.

Gopuff's pages do not describe signing at all. Because delivery goes through Svix, each request carries Svix's three headers, `svix-id`, `svix-timestamp` and `svix-signature`, and is signed with the endpoint's secret from the portal. The scheme below is Svix's, from Svix's verification guide.

The payloads are small and carry no event-type field. Each one is the bare resource: an `order` object, a driver location or a delivery window.

## Gopuff webhook features

| Feature | Details |
| --- | --- |
| Configuration | Svix App Portal, opened with a link from `POST /devportal/v1/webhooks/app-portal-link` |
| Portal link lifetime | 24 hours |
| Event selection | Per endpoint, in the App Portal |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{raw body}`, base64, as `v1,<signature>` |
| Secret | Per endpoint, `whsec_` prefixed, shown in the App Portal |
| Event type in payload | None. Tell payloads apart by shape |
| Delivery history and replay | In the App Portal |
| Retries and timeout | Not documented by Gopuff |
| Dev environment | Only the `Cancelled` order status can be triggered |

## Common events

Gopuff lists three event types for the App Portal, and documents two more payloads without naming their event types:

| Payload | Event type | Fires when |
| --- | --- | --- |
| Order status v2 | `order.status.v2` | An order's status changes. Gopuff recommends it for all integrations |
| Order status | `order.status` | An order's status changes, with the full Order object. End of support was 08/15/2025 |
| Order delivered | `order.delivered` | An order is delivered. No payload is documented |
| Delivery status | Not documented | The driver's location updates while the order is in `Delivery` |
| Delivery ETA | Not documented | An order is created, then periodically until it reaches a terminal state |

Orders move through `Pending`, `Packing_Now`, `Packed`, `Delivery`, `Finished` and `Cancelled`. Gopuff does not send an order status event for `Pending`.

Here is Gopuff's `order.status.v2` example:

```json
{
  "order": {
    "id": "0123456789",
    "location_id": 482,
    "partner_order_id": "ABC-512351",
    "status": "Delivery",
    "cancel_reason": null
  }
}

```

`id` is Gopuff's order ID, `location_id` the fulfilment centre, `partner_order_id` your own order reference, and `cancel_reason` is populated only when `status` is `Cancelled`.

A delivery status payload carries the driver's position and whether this order is next:

```json
{
  "id": "0123456789",
  "driver": {
    "location": { "latitude": 37.7749, "longitude": -122.4194 }
  },
  "partner_order_id": "ABC-512351",
  "delivering_multiple_orders": true,
  "delivering_this_order_next": false
}

```

A delivery ETA payload carries an ISO 8601 window, which Gopuff says "may change as the order progresses":

```json
{
  "id": "0123456789",
  "partner_order_id": "ABC-512351",
  "expected_delivery_time": {
    "min": "2025-08-21T18:35:00.000Z",
    "max": "2025-08-21T18:50:00.000Z"
  }
}

```

## Setting up Gopuff webhooks

1. Request an App Portal link with your partner credentials:

```bash
curl -X POST "https://fulfillment.partners.gopuff.dev/devportal/v1/webhooks/app-portal-link" \
  -H "Content-Type: application/json" \
  -H "Gopuff-Partner: $GOPUFF_PARTNER" \
  -H "Gopuff-Access-Token: $GOPUFF_ACCESS_TOKEN"

```

The response is `{"url": "https://app.svix.com/login?..."}`. The link expires after 24 hours.

1. Open the link, add your endpoint URL and select the event types you want. Choose `order.status.v2` rather than `order.status`.
2. Copy the endpoint's signing secret and store it server-side as `GOPUFF_WEBHOOK_SECRET`.
3. In the Dev environment, cancel a test order with the cancel order API to trigger a `Cancelled` status event. That is the only status you can trigger there.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 gopuff --path /webhooks/gopuff`) gives you a public HTTPS URL that forwards to your local server, with no account required. Add that URL as an endpoint in the App Portal.

## Securing Gopuff webhooks

Build the signed content as `{svix-id}.{svix-timestamp}.{raw body}`, compute an HMAC-SHA256 with the base64-decoded part of the secret after `whsec_` as the key, encode the result as base64, and compare it in constant time against each `v1,` entry in the space-delimited `svix-signature` header. Svix notes that the header can hold several signatures, so accept the request if any one of them matches.

Svix also sends the timestamp so you can reject old requests. That check is optional in the code below and off by default. If you turn it on, remember that a replay from the App Portal or from Hookdeck keeps its original timestamp.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyGopuffSignature(rawBody, headers, secret, toleranceSeconds = 0) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
  if (!/^\d+$/.test(timestamp)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/gopuff', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyGopuffSignature(req.body, req.headers, process.env.GOPUFF_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  // Route by shape: payload.order, payload.driver or payload.expected_delivery_time.
  res.sendStatus(200);
});

```

The same check in Python:

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

def verify_gopuff_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
        return False
    if not timestamp.isdigit():
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_gopuff_signature(raw_body, request.headers, os.environ["GOPUFF_WEBHOOK_SECRET"])

```

Svix publishes a test vector you can use. With the secret `whsec_plJ3nmyCDGBKInavdOK15jsl`, the ID `msg_loFOjxBNrRLzqYUf`, the timestamp `1731705121` and the body `{"event_type":"ping","data":{"success":true}}`, the signature is `v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0=`.

The official `svix` libraries verify the same signature, but they always enforce a timestamp window, so they reject a replayed event with an old timestamp.

> **Make Gopuff webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every Gopuff delivery, and durably queues order, driver and ETA updates before your service sees them.

## Gopuff webhook limitations and pain points

### No event type in the payload

**The Problem:** Your handler receives a body and cannot tell which event it is.

**Why It Happens:** Gopuff's payloads are the bare resource, with no `type` or `event` field, and Gopuff documents no event header. The event type exists only in the App Portal's settings and logs. `order.status` and `order.status.v2` both use a top-level `order` object.

**Workarounds:**

* Route by shape: an `order` key is an order status update, `driver` is a delivery status update, and `expected_delivery_time` is an ETA.
* Point different event types at different endpoint URLs if you need to separate them without inspecting the body.
* Keep a default branch that logs and acknowledges anything you do not recognise.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on the presence of `order`, `driver` or `expected_delivery_time` and route each kind of update to its own destination.

### Signing and retries are undocumented by Gopuff

**The Problem:** You cannot find Gopuff's signing secret, retry schedule or timeout in its docs.

**Why It Happens:** Gopuff hands endpoint management to Svix and documents only the portal link and the payloads. The signing secret lives in the App Portal, and delivery history there is the only view of failures Gopuff mentions.

**Workarounds:**

* Use the endpoint secret from the App Portal and Svix's verification scheme.
* Check delivery history in the App Portal after any outage, and replay failed events from there. Remember the portal link expires after 24 hours, so request a new one when you need it.
* Respond quickly, since no timeout is stated.

**How Hookdeck Can Help:** Hookdeck acknowledges every delivery immediately, logs it with full headers and body, and retries delivery to your service on [a schedule you set](/docs/retries), so you are not relying on an undocumented upstream policy.

### Driver location updates are frequent

**The Problem:** Delivery status webhooks arrive in bursts while drivers are on the road, and your handler struggles to keep up.

**Why It Happens:** Gopuff sends a delivery status update "whenever a driver's location is updated while an order is in the `Delivery` status", and says the frequency depends on the driver's device, network and movement.

**Workarounds:**

* Write the latest position per order and overwrite it, rather than storing every point.
* Keep the handler to verify, write and answer.
* Use `delivering_this_order_next` to decide when to show live tracking to the customer.

**How Hookdeck Can Help:** Hookdeck accepts the bursts at its own edge and delivers them to your service at a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) you choose.

## Best practices

### Verify against the raw body

The signature covers the exact bytes. Mount `express.raw()` on the route, read `await request.body()` in FastAPI, or use `Buffer.from(await request.arrayBuffer())` in a Next.js route handler.

### Use `svix-id` for deduplication

Gopuff payloads carry no event ID, but Svix's `svix-id` header is the same on every retry of a message. Store it before processing and skip ones you have seen. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Join on `partner_order_id`

Every payload includes `partner_order_id`, your own order reference. Use it to find the order in your system rather than storing Gopuff's ID mappings separately.

### Move to `order.status.v2`

`order.status` reached end of support on 08/15/2025. Subscribe to `order.status.v2` and read only the fields it carries: `id`, `location_id`, `partner_order_id`, `status` and `cancel_reason`.

### Acknowledge fast and process asynchronously

Verify, store and answer, then do the customer notifications and order updates in a worker. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Gopuff delivers its partner webhooks through Svix. Register endpoints in the App Portal, verify `svix-signature` over `{svix-id}.{svix-timestamp}.{raw body}` with the `whsec_` secret, tell payloads apart by shape, join on `partner_order_id`, and use `order.status.v2` for status changes.

[Hookdeck Event Gateway](https://hookdeck.com) verifies the Svix signature on every Gopuff delivery and holds each update until your service has processed it, so bursts of driver locations or a deploy on your side don't cost you order updates.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Gopuff webhooks reliably in minutes.