Skip to main content

Request, response & webhook signing

In addition to authenticating with an API key, every request you send must be signed, and Orchestr signs every response and webhook it sends back. Signing uses HMAC-SHA256 with your account's signing secret and protects against tampering and replay.

You sign with the same signing secret Orchestr uses to sign responses and webhooks to you. Signing secrets are created and managed from the Orchestr Dashboard (see Credential rotation); the full value is shown only once at creation.

The X-Signature header

Signatures travel in an X-Signature header on both inbound requests and outbound responses/webhooks:

X-Signature: t=1719056400, v1=4f2c...e9a1
  • t — the Unix timestamp (seconds since the epoch) used when computing the signature.
  • v1 — a lowercase hex HMAC-SHA256 signature.

A header may carry more than one v1 value:

X-Signature: t=1719056400, v1=4f2c...e9a1, v1=9b73...22df

This happens during signing-secret rotation: Orchestr signs outbound payloads with every active signing secret and produces one v1 per secret. Verification succeeds if any v1 matches, so your integration keeps working throughout a rotation.

What gets signed

The signature is computed over a single string built by concatenating these parts with no separators. The string differs slightly between directions:

DirectionSigned stringNotes
Your request → Orchestrtimestamp + path + bodypath is the request path including /v1/..., excluding the query string. body is the exact request body; use "" for requests with no body (e.g. GET).
Orchestr response → youtimestamp + bodyNo path. body is the exact response bytes.
Orchestr webhook → youtimestamp + bodyNo path. body is the exact webhook bytes (the envelope).
Sign the exact bytes you send

HMAC is computed over bytes. Serialize your JSON once, sign that exact string, and send that exact string — do not re-serialize, pretty-print, or reorder keys between signing and sending, or the signature will not match. Likewise, when verifying a response or webhook, use the raw body bytes as received, before any JSON parsing.

The signed string is UTF-8 encoded, HMAC-SHA256 is keyed with the signing secret (also UTF-8), and the result is lowercase hex.

Worked example

Signing a checkout-session creation request:

secret = sk_signing_secret_value
timestamp = 1719056400
path = /v1/checkout/sessions
body = {"amount":"42.00","currency":"GBP","merchant_reference":"order-1001"}

signed string = 1719056400/v1/checkout/sessions{"amount":"42.00","currency":"GBP","merchant_reference":"order-1001"}
v1 = HMAC_SHA256(secret, signed string) → lowercase hex
X-Signature = t=1719056400, v1=<that hex>

Replay protection

When Orchestr verifies your request, it rejects signatures whose timestamp is:

  • older than 300 seconds (5 minutes), or
  • more than 10 seconds in the future (to tolerate minor clock skew).

This freshness check is something Orchestr applies to your inbound requests, so keep the clock on the server that signs your requests synced (NTP) to stay within the window. When you verify Orchestr's responses and webhooks you do not need to check the timestamp age — Orchestr controls when those are sent — so the examples below simply recompute and compare the HMAC. Always use a constant-time comparison to avoid timing attacks.

Signing a request

Compute X-Signature over timestamp + path + body and send it alongside your Authorization header.

SIGNING_SECRET="$ORCHESTR_SIGNING_SECRET"
REQ_PATH="/v1/checkout/sessions"
BODY='{"amount":"42.00","currency":"GBP","merchant_reference":"order-1001","success_url":"https://shop.example.com/ok","cancel_url":"https://shop.example.com/cancel","customer":{"email":"jane@example.com"}}'
TS=$(date +%s)

# openssl prints "(stdin)= <hex>"; strip the prefix.
SIG=$(printf '%s' "${TS}${REQ_PATH}${BODY}" \
| openssl dgst -sha256 -hmac "$SIGNING_SECRET" | sed 's/^.*= //')

curl -sS -X POST "https://api.sandbox.upprove.com${REQ_PATH}" \
-H "Authorization: Bearer $ORCHESTR_SECRET_KEY" \
-H "X-Signature: t=${TS}, v1=${SIG}" \
-H "Content-Type: application/json" \
--data-raw "$BODY"

Verifying a response or webhook

To verify a payload Orchestr sent you, recompute the HMAC over timestamp + body (no path) using the raw body bytes, then check it against any v1 in the header with a constant-time compare. The same function works for both API responses and webhooks.

# Verify a saved response body against its X-Signature header.
SIGNING_SECRET="$ORCHESTR_SIGNING_SECRET"
HEADER="t=1719056400, v1=4f2c...e9a1" # the X-Signature you received
BODY="$(cat response-body.json)" # raw bytes, exactly as received

TS=$(printf '%s' "$HEADER" | sed -n 's/.*t=\([0-9]*\).*/\1/p')
EXPECTED=$(printf '%s' "${TS}${BODY}" \
| openssl dgst -sha256 -hmac "$SIGNING_SECRET" | sed 's/^.*= //')

# Succeeds if EXPECTED appears among the v1 values in the header.
echo "$HEADER" | grep -q "v1=${EXPECTED}" && echo "valid" || echo "INVALID"
tip

The verification function is identical for API responses and webhooks because both are signed over timestamp + body. Reuse one helper for both.

Testing signed requests in Postman

To exercise the API from Postman, set two collection variables — apiKey and signingSecret — and add the following Pre-request Script to your collection (or to an individual request). It signs every request automatically by computing X-Signature over timestamp + path + body and setting the Authorization and X-Signature headers:

// Timestamp in SECONDS
const ts = Math.floor(Date.now() / 1000).toString();

// Path only — no host, no query string (must match server getRequestURI())
const path = pm.request.url.getPath();

// Raw body (empty string if none)
let body = "";
if (pm.request.body && pm.request.body.mode === "raw" && pm.request.body.raw) {
body = pm.request.body.raw;
}

// signed = timestamp + path + body (no separators)
const signed = ts + path + body;

const secret = pm.collectionVariables.get("signingSecret");
const sig = CryptoJS.HmacSHA256(signed, secret).toString(CryptoJS.enc.Hex);

// Set headers
pm.request.headers.upsert({
key: "Authorization",
value: "Bearer " + pm.collectionVariables.get("apiKey")
});
pm.request.headers.upsert({
key: "X-Signature",
value: "t=" + ts + ", v1=" + sig
});
note

This signs timestamp + path + body, which is correct for requests. Responses and webhooks are signed over timestamp + body only — use the verification helpers above for those. Send the request body as raw JSON so the signed bytes match what is transmitted.

Next step

See Webhooks to receive and verify asynchronous events, or jump into the API Reference.