Guide to Medusa Webhooks: Features and Best Practices
Medusa webhooks tell you what is happening to your Medusa Cloud deployments: a build starts, succeeds, fails or is cancelled, and a deployment goes out or fails. If you host your Medusa commerce application on Medusa Cloud, webhooks let you post release notes, alert the team when a build breaks, run smoke tests after a deployment, or update a status board, without polling the dashboard.
This guide covers how Medusa Cloud webhooks are configured and signed, the build and deployment events and their payloads, Medusa's retry and secret rotation rules, and the practices that keep a receiver reliable.
What are Medusa webhooks?
Medusa webhooks are a Medusa Cloud feature, available on the Launch, Scale and Enterprise plans. They report on your organisation's builds and deployments. They are not commerce events: a self-hosted Medusa application has no built-in outbound webhooks, and application events such as orders are handled inside Medusa by subscribers.
Each organisation has a single webhook endpoint, which receives every event from every project and environment. There is no per-event subscription, so your receiver gets all eight event types and decides what to act on.
Every request carries an X-Medusa-Signature header of the form t=<timestamp>,v1=<signature>. The signature is a hex HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with the endpoint's signing secret.
Medusa webhook features
| Feature | Details |
|---|---|
| Availability | Medusa Cloud, Launch, Scale and Enterprise plans |
| Configuration | Cloud dashboard: open a project, then Webhooks > Endpoints > Create endpoint |
| Endpoints | One per organisation, receiving every event for every project and environment |
| Event selection | None. Every event is sent |
| Signature header | X-Medusa-Signature: t=<unix seconds>,v1=<hex> |
| Scheme | HMAC-SHA256, hex |
| Signed content | <t>.<raw request body> |
| Secret | Generated by Medusa per endpoint |
| Rotation | Invalidates the old secret immediately |
| Other headers | X-Medusa-Event, X-Medusa-Delivery, User-Agent: MedusaCloud-Webhook/1.0 |
| Response window | A 2xx within 10 seconds |
| Redirects | Not followed. A 3xx counts as a failure |
| Retries | Exponential backoff from 5 minutes, doubling, for up to 24 hours after the first attempt |
| Event history | Delivery status in the Cloud dashboard |
Medusa does not publish source IP addresses for webhooks.
Common events
Medusa Cloud sends eight events. The name is in the body's type field and in the X-Medusa-Event header.
| Event | Fires when |
|---|---|
build.created | Medusa creates a build for an environment, after a push to its branch |
build.succeeded | A build completes successfully |
build.failed | A build fails |
build.canceled | A build is canceled |
deployment.created | Medusa creates a deployment for an environment, after a build succeeds |
deployment.succeeded | A deployment completes successfully |
deployment.failed | A deployment fails |
deployment.canceled | A deployment is canceled |
Every event uses the same envelope: id, type, data and created_at. Here is Medusa's deployment.succeeded example:
{
"id": "whev_01HXYZ6789ABCDEF",
"type": "deployment.succeeded",
"created_at": "2024-11-12T10:10:00.000Z",
"data": {
"deployment": { "id": "depl_01HXYZ6789ABCDEF", "status": "succeeded" },
"build": {
"id": "build_01HXYZ5678ABCDEF",
"commit_hash": "a1b2c3d4e5f6",
"commit_message": "Add new product feature",
"commit_author": "Jane Doe"
},
"organization": { "id": "org_01HXYZ9012ABCDEF", "name": "Acme Corp" },
"project": {
"id": "proj_01HXYZ3456ABCDEF",
"name": "My Storefront",
"handle": "my-storefront",
"repository": "https://github.com/acme/storefront",
"region": "eu-west-1"
},
"environment": {
"id": "projenv_01HXYZ7890ABCDEF",
"name": "Production",
"handle": "production",
"branch": "main"
}
}
}
Build events carry build, organization, project and environment, and deployment events add deployment. The status field always matches the event. commit_message and commit_author are empty strings when Medusa doesn't have them.
Two fields are easy to misread. id identifies the event and is reused on every retry, so it is your deduplication key. created_at is when Medusa queued the event, not when the build or deployment changed.
Setting up Medusa webhooks
- In the Medusa Cloud dashboard, switch to the right organisation and open any project.
- Click Webhooks > Endpoints in the sidebar, then Create endpoint.
- Enter the HTTPS Endpoint URL and leave Enabled on.
- Click Create endpoint.
- On the endpoint card, reveal or copy the Signing Secret and store it server-side, for example as
MEDUSA_WEBHOOK_SECRET.
Because there is only one endpoint per organisation, every project and environment shares it. If several teams or services need these events, fan them out from that one endpoint.
If you receive through Hookdeck, create a Medusa source, use its URL as the endpoint URL, and paste the signing secret into the source's Webhook Signing Secret field. Hookdeck can then route events to different destinations by project, environment or event type.
Securing Medusa webhooks
Split X-Medusa-Signature on commas, take t and v1, build <t>.<raw body>, compute an HMAC-SHA256 with the signing secret as a UTF-8 key, encode it as hex, and compare it with v1 in constant time.
const crypto = require('crypto');
const express = require('express');
function parseMedusaHeader(header) {
const parts = {};
for (const pair of header.split(',')) {
const i = pair.indexOf('=');
if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
}
return parts;
}
function verifyMedusaSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
if (!signatureHeader || !secret) return false;
const { t, v1 } = parseMedusaHeader(signatureHeader);
if (!t || !v1 || !/^\d+$/.test(t)) return false;
// Optional freshness check, off by default (see below).
if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');
const received = Buffer.from(v1.toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/medusa', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.MEDUSA_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyMedusaSignature(req.body, req.get('x-medusa-signature'), secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.type. Deduplicate on event.id (also in X-Medusa-Delivery).
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import time
def verify_medusa_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
if not signature_header or not secret:
return False
parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or not t.isdigit():
return False
# Optional freshness check, off by default.
if tolerance_seconds > 0 and abs(int(time.time()) - int(t)) > tolerance_seconds:
return False
signed = t.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1.lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_medusa_signature(raw_body, request.headers.get("x-medusa-signature"),
# os.environ["MEDUSA_WEBHOOK_SECRET"])
Medusa's own sample adds a freshness check on t with a default window of 300 seconds, and notes that Medusa signs every delivery attempt when it sends it, so its own retries pass. A queue in front of your receiver is different: when Hookdeck retries an event to your service, or you replay one, the original header goes with it, so a 5-minute window would reject those late deliveries. Keep the window off or wide behind a queue, and rely on id for deduplication.
Inside a Medusa application, the docs verify in a middleware and use the preserveRawBody body parser option so the raw body is available as req.rawBody.
Make Medusa webhooks production-ready. Hookdeck Event Gateway verifies the
X-Medusa-Signatureheader, answers Medusa within its 10-second window, and durably queues every build and deployment event before your service sees it.
Medusa webhook limitations and pain points
One endpoint for everything
The Problem: Production alerts, staging noise and every project's builds all land on the same URL.
Why It Happens: Medusa allows one endpoint per organisation, with no event filter. Every project, every environment and all eight event types share it.
Workarounds:
- Route on
type,data.project.handleanddata.environment.handlein your receiver. - Fan events out to the services and channels that care about them.
How Hookdeck Can Help: Hookdeck filters can match on type and on fields such as data.environment.handle, so production deployment failures can go to on-call while staging builds go to a chat channel, all from one Medusa endpoint.
Secret rotation has no overlap
The Problem: Rotating the signing secret causes a burst of failed deliveries.
Why It Happens: Medusa invalidates the old secret the moment it generates the new one. Any delivery signed after that fails verification until your receiver has the new secret.
Workarounds:
- Rotate during a quiet period, and update the secret in your receiver straight away.
- Temporarily accept either secret in your receiver while the change rolls out.
- Check the Cloud dashboard for deliveries that failed during the switch. Medusa's retries will resend them.
How Hookdeck Can Help: With Hookdeck verifying, you update the secret in one place. Requests that fail verification during the switch are recorded, so you can see exactly which ones were affected.
Tight response window, no redirects
The Problem: Deliveries fail even though your receiver eventually handles them.
Why It Happens: Medusa waits 10 seconds for a 2xx and does not follow redirects. Slow handlers, a trailing-slash redirect or an HTTP-to-HTTPS redirect all count as failures.
Workarounds:
- Register the exact final HTTPS URL.
- Respond as soon as the signature is verified and do slow work, such as running smoke tests, afterwards.
How Hookdeck Can Help: Hookdeck responds to Medusa immediately and delivers to your service with its own timeout and retries.
Events can arrive out of order
The Problem: A build.created event is processed after the build.succeeded for the same build.
Why It Happens: Builds and deployments produce several events in quick succession, and retries are spaced from 5 minutes up. Medusa does not promise ordering.
Workarounds:
- Store the latest known status per build and deployment ID, and ignore events that would move it backwards.
- Treat terminal statuses (
succeeded,failed,canceled) as final.
How Hookdeck Can Help: Hookdeck keeps every event and its delivery history, so you can see the order events arrived in and replay them if your state needs rebuilding.
Best practices
Verify against the raw body
The signature covers the exact bytes Medusa sent. In Express, mount express.raw() on the route. In a Medusa application, use preserveRawBody. In FastAPI, read await request.body().
Deduplicate on id
Medusa reuses id (and X-Medusa-Delivery) on every retry. Store it and skip events you have already processed. See our guide to webhook idempotency.
Ignore events you don't need
Because you can't filter at Medusa, expect every event type and return 200 for the ones you don't handle. Returning an error for an uninteresting event only produces retries.
Watch the endpoint toggle
A disabled endpoint receives nothing, and Medusa sends no events at all while it is off. If events stop, check the toggle on the endpoint card before anything else.
Acknowledge fast and process asynchronously
Return 200 within Medusa's 10-second window and do the real work afterwards. See why to process webhooks asynchronously.
Conclusion
Medusa Cloud signs every webhook with a hex HMAC-SHA256 of <t>.<raw body> in X-Medusa-Signature, keyed with the endpoint's signing secret. Verify against the raw bytes, keep any timestamp check optional, deduplicate on id, route by project and environment yourself since there is one endpoint per organisation, and plan secret rotations, because the old secret stops working immediately.
Hookdeck Event Gateway sits in front of your receiver, checks every Medusa signature, routes events by project, environment or type, and holds each one until your service has processed it.
Get started with Hookdeck for free and handle Medusa webhooks reliably in minutes.