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:
{
"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
- 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.
- Open the link, add your endpoint URL and select the event types you want. Choose
order.status.v2rather thanorder.status. - Copy the endpoint's signing secret and store it server-side as
GOPUFF_WEBHOOK_SECRET. - In the Dev environment, cancel a test order with the cancel order API to trigger a
Cancelledstatus 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_, the ID msg_loFOjxBNrRLzqYUf, the timestamp 1731705121 and the body {"event_, 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
orderkey is an order status update,driveris a delivery status update, andexpected_delivery_timeis 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_to decide when to show live tracking to the customer.this_ order_ next
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.