Skip to content

Signing & authentication

Every call in either direction — you calling the platform, or the platform calling your webhooks — uses the same HMAC-SHA256 request-signing scheme. @moosehq/provider-sdk's ProviderClient signs your outbound calls automatically; you only need this page in full if you're diagnosing a failure, implementing a webhook handler, or signing a call the SDK doesn't cover.

The canonical string

method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body
  • method — the HTTP method, uppercase (POST).
  • path — the URL path only, no scheme, host, or query string (e.g. /v1/wallet/transaction).
  • timestamp — Unix seconds, as a string, matching the X-Timestamp header exactly.
  • nonce — a random, single-use token, matching the X-Nonce header exactly. generateNonce() produces 16 random bytes, hex-encoded.
  • body — the exact bytes sent as the request body. For a request with no body, this is an empty string.

HMAC-SHA256(secret, canonicalString), hex-encoded, is the signature.

Headers

HeaderMeaning
X-Tenant-IDThe tenant asserting this call. When you sign a request, this is your own provider tenant ID. When the platform signs a webhook call to you, this is still your provider tenant ID — the platform is asserting "this call is for you," not naming itself.
X-TimestampUnix seconds, matching the timestamp in the canonical string.
X-NonceThe random single-use value, matching the nonce in the canonical string.
X-SignatureThe hex-encoded HMAC-SHA256 signature.

signRequest/generateNonce (exported by @moosehq/provider-sdk) build all four for you; explainSignature computes the same signature but also returns the canonical string it hashed, for diagnosing a mismatch — see Debugging.

Clock skew

The platform rejects a request whose X-Timestamp is too far from its own clock — by default, more than 300 seconds (5 minutes) in either direction. Keep the clock on whatever server signs your requests (and verifies inbound webhooks) synced via NTP; a skewed container or VM clock is a common, otherwise-invisible cause of intermittent 401s. onDebug's clockSkewMs (see Debugging) surfaces this directly.

Nonces are single-use

Each X-Nonce may only be used once. A nonce is folded into the signature itself, so an attacker who captures a signed request can't simply swap in a fresh nonce and replay it — the signature would no longer match. Reusing a nonce (even with an otherwise-correct signature) is rejected as a replay. Generate a fresh nonce for every signed request — ProviderClient and signRequest/generateNonce already do this for you.

Secret rotation

If your integration contact rotates your tenant's secret, the platform accepts signatures from your previous secret for a grace period after rotation (7 days by default) — so you have a window to switch your signing code over without a hard, instant cutover. This grace period only applies to the platform verifying your outbound signatures. Rotate as soon as you're able, and don't rely on the grace window being open — it's a migration aid, not a steady-state fallback.

The reverse direction has no such grace period: verifyPlatformSignature (what you use to verify a webhook call from the platform) only ever checks against your own single current secret. If you rotate your secret, update it on both sides at the same moment — there's no "either secret works" window when you're the one verifying.

Error surface

A signature-layer failure is distinguished from an ordinary business-logic error by its body shape — see Errors & retry for the full picture:

FailureStatusBody
Bad, missing, or expired-timestamp signature401Plain text: invalid signature
Missing or oversized X-Nonce (max 128 characters)400JSON: { "error": "invalid nonce" }
A nonce that's already been used401JSON: { "error": "replayed request" }

The plain-text 401 happens before your request reaches any business logic at all — a valid signature is a precondition for every signed endpoint, inbound or outbound.

Symmetry: outbound vs. inbound

The construction is identical in both directions:

  • Outbound (you → platform): signRequest/ProviderClient build the canonical string from the request you're about to send and attach the resulting headers.
  • Inbound (platform → you): verifyPlatformSignature rebuilds the same canonical string from the request you received and compares it against X-Signature.

The one thing to get right on the inbound side: body must be the exact raw bytes the platform signed. If your web framework parses the body into an object before your handler runs (JSON middleware, form parsing, etc.), you've already lost the exact byte sequence — register your webhook routes with a raw-body reader instead. See Session revoke webhook for a worked Express example using express.text({ type: '*/*' }).