Gareth Wilson Gareth Wilson

Guide to Gopuff Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSvix App Portal, opened with a link from POST /devportal/v1/webhooks/app-portal-link
Portal link lifetime24 hours
Event selectionPer endpoint, in the App Portal
Signature headerssvix-id, svix-timestamp, svix-signature
SchemeHMAC-SHA256 over {svix-id}.{svix-timestamp}.{raw body}, base64, as v1,<signature>
SecretPer endpoint, whsec_ prefixed, shown in the App Portal
Event type in payloadNone. Tell payloads apart by shape
Delivery history and replayIn the App Portal
Retries and timeoutNot documented by Gopuff
Dev environmentOnly 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:

PayloadEvent typeFires when
Order status v2order.status.v2An order's status changes. Gopuff recommends it for all integrations
Order statusorder.statusAn order's status changes, with the full Order object. End of support was 08/15/2025
Order deliveredorder.deliveredAn order is delivered. No payload is documented
Delivery statusNot documentedThe driver's location updates while the order is in Delivery
Delivery ETANot documentedAn 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:

{
  "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:

{
  "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":

{
  "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:
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 (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.

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:

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 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 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, 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 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.

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.

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 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 for free and handle Gopuff webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

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