> ## Documentation Index
> Fetch the complete documentation index at: https://help.the-meridian.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Forwarded webhooks

Meridian receives your app's Shopify webhooks over [Pub/Sub](/pub-sub) and forwards the ones you routed to your app. Meridian re-serializes the event, so Shopify's own HMAC can no longer validate the body. Verify **Meridian's** signature instead.

## Credentials

On Meridian hosting, your next deploy injects `MERIDIAN_WEBHOOK_SECRET` and `MERIDIAN_API_KEY` automatically, along with `MERIDIAN_APP_ID` and `MERIDIAN_API_URL`. The signing secret verifies forwards and the host challenge. The API key authenticates SDK calls such as event tracking.

For local development or hosting elsewhere, declare these credentials yourself. A manually declared `MERIDIAN_WEBHOOK_SECRET` or `MERIDIAN_API_KEY` also takes precedence on Meridian hosting. See [Credentials and environment](/sdk-credentials#on-meridian-hosting) for the managed values, override rules, and staging API configuration.

## The receiver route

One route handles both the verification handshake and real events. Try the challenge first, then fall through:

```ts app/routes/webhooks.shopify.tsx theme={null}
import {
  meridianChallengeResponse,
  verifyMeridianWebhook,
} from "@the-meridian/sdk/server"

export const action = async ({ request }) => {
  const raw = await request.text()
  const secret = process.env.MERIDIAN_WEBHOOK_SECRET ?? ""

  // 1. A verification handshake? Answer it and stop.
  const challenge = await meridianChallengeResponse(raw, null, secret, {
    headers: request.headers,
  })
  if (challenge) return Response.json(challenge)

  // 2. Otherwise it is a forwarded webhook.
  if (!(await verifyMeridianWebhook({ rawBody: raw, headers: request.headers, secret }))) {
    return new Response("invalid signature", { status: 401 })
  }

  const topic = request.headers.get("X-Shopify-Topic")
  // handle the event
  return new Response(null, { status: 200 })
}
```

<Warning>
  Verify against the **raw** body, exactly as received. Parsing and re-serializing the JSON changes the bytes the signature was computed over, and verification fails.
</Warning>

The default forward path is `/webhooks/shopify`. Your dashboard's webhook configuration decides which topics reach it. See [Pub/Sub](/pub-sub).

## The host verification challenge

An app hosted outside Meridian (Heroku, Fly, your own server) receives forwarded webhooks at an external domain. Declare its `https://` origin on the app's **Settings > Domains** page, under **External domains**, then click **Verify**. Meridian sends a signed `POST` to your forward path on that origin with the body `{ type: "meridian/verify", nonce, app_id }`, expecting an answer only the holder of your signing secret can compute. A domain that answers is verified: your app runs there and its SDK holds the secret. It does not prove who owns the domain.

Then pick the domain in the **Forward host** select on the **Pub/Sub > Webhooks** page. The select lists your Meridian deployment when the app is deployed, and every verified external domain. Pending and failed domains are listed but can't be picked, so the forward host you choose takes effect from the next event. See [External domains](/custom-domain#external-domains).

`meridianChallengeResponse` computes the answer. A non-null result is the JSON to respond with, at HTTP 200. `null` means this is not a challenge, or the signature did not verify. Fall through to the event path.

## Replay protection

`verifyMeridianWebhook` is the one to use. Forwards carry two headers:

| Header | Contents |
| - | - |
| `X-Meridian-Timestamp` | Unix seconds the forward was signed at |
| `X-Meridian-Signature-V2` | HMAC-SHA256 over `"{timestamp}.{body}"`, as `sha256=<hex>` |

The timestamp has to be within 300 seconds of now, in either direction, and a signature has to match, so a captured request stops verifying once the window passes. Override it with `toleranceSeconds`.

A request arriving **without** those headers falls back to the legacy body-only check, which has no replay protection. Pass `requireTimestamp: true` to refuse the fallback instead.

`verifyForwardedWebhook(rawBody, signatureHeader, secret)` is that legacy check on its own. Prefer `verifyMeridianWebhook`.

## Rotating the signing secret

The signing secret comes from your app in the Meridian dashboard. Meridian hosting injects it as `MERIDIAN_WEBHOOK_SECRET`; elsewhere, set that variable yourself.

When you rotate it, the v2 header carries a signature for **both** secrets for 24 hours, so a receiver still holding the old value keeps verifying while you roll out the new one. Two details matter during that window:

* The legacy body-only check carries one signature, so it switches to the new secret **immediately**. An app still on `verifyForwardedWebhook` gets no grace period.
* The challenge answer is accepted from either secret for the same 24 hours.

Redeploy each Meridian-hosted environment promptly to receive the new secret. If you set `MERIDIAN_WEBHOOK_SECRET` yourself, update its value before redeploying.

## What the SDK registers for you

You never declare webhook topics. [`afterAuth`](/sdk-app#afterauth) reconciles a shop's Pub/Sub subscriptions from the set Meridian resolves (the managed billing topics plus whatever you routed), and unions the managed ones in even when the configuration is unreachable, so an outage cannot drop billing events.

Four topics are managed by the platform and always registered:

| Topic | Why Meridian needs it |
| - | - |
| `APP_UNINSTALLED` | Ends the install, and is how uninstall reaches Meridian |
| `APP_SUBSCRIPTIONS_UPDATE` | Flips a pending subscription to active, and tracks status changes |
| `APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT` | Drives the [approaching cap](/approaching-capped-amount) trigger |
| `APP_PURCHASES_ONE_TIME_UPDATE` | Records [one-time charges](/one-time-charge) |

Apps on the same Meridian environment publish to that environment's Pub/Sub topic, so each subscription is named with your app's ID for routing. The SDK asks the API which topic to target, so you never configure it. `registerMeridianWebhooks` registers the four managed topics alone and, because it takes no API key, defaults to the production topic (pass `delivery` to target another environment); [`registerWebhooks`](/sdk-app#re-registering-webhooks) on the app object reconciles the full set, including your own topics, against the served target.

## Signature constants

If you build the verification yourself, the header names and the default window are exported: `MERIDIAN_SIGNATURE_HEADER`, `MERIDIAN_SIGNATURE_V2_HEADER`, `MERIDIAN_TIMESTAMP_HEADER` and `MERIDIAN_SIGNATURE_TOLERANCE_SECONDS`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.