Gareth Wilson Gareth Wilson

Guide to Medusa Webhooks: Features and Best Practices

Published


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

FeatureDetails
AvailabilityMedusa Cloud, Launch, Scale and Enterprise plans
ConfigurationCloud dashboard: open a project, then Webhooks > Endpoints > Create endpoint
EndpointsOne per organisation, receiving every event for every project and environment
Event selectionNone. Every event is sent
Signature headerX-Medusa-Signature: t=<unix seconds>,v1=<hex>
SchemeHMAC-SHA256, hex
Signed content<t>.<raw request body>
SecretGenerated by Medusa per endpoint
RotationInvalidates the old secret immediately
Other headersX-Medusa-Event, X-Medusa-Delivery, User-Agent: MedusaCloud-Webhook/1.0
Response windowA 2xx within 10 seconds
RedirectsNot followed. A 3xx counts as a failure
RetriesExponential backoff from 5 minutes, doubling, for up to 24 hours after the first attempt
Event historyDelivery 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.

EventFires when
build.createdMedusa creates a build for an environment, after a push to its branch
build.succeededA build completes successfully
build.failedA build fails
build.canceledA build is canceled
deployment.createdMedusa creates a deployment for an environment, after a build succeeds
deployment.succeededA deployment completes successfully
deployment.failedA deployment fails
deployment.canceledA 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

  1. In the Medusa Cloud dashboard, switch to the right organisation and open any project.
  2. Click Webhooks > Endpoints in the sidebar, then Create endpoint.
  3. Enter the HTTPS Endpoint URL and leave Enabled on.
  4. Click Create endpoint.
  5. 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-Signature header, 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.handle and data.environment.handle in 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.


Gareth Wilson

Gareth Wilson

Product Marketing

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