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:
| Direction | Signed string | Notes |
|---|---|---|
| Your request → Orchestr | timestamp + path + body | path 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 → you | timestamp + body | No path. body is the exact response bytes. |
| Orchestr webhook → you | timestamp + body | No path. body is the exact webhook bytes (the envelope). |
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.
- cURL
- Node.js
- Python
- PHP
- Java
- C#
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"
const crypto = require('crypto');
function buildSignatureHeader(signingSecret, path, body) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const signed = timestamp + path + (body ?? '');
const v1 = crypto.createHmac('sha256', signingSecret).update(signed, 'utf8').digest('hex');
return `t=${timestamp}, v1=${v1}`;
}
const path = '/v1/checkout/sessions';
const body = JSON.stringify({
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'},
});
const res = await fetch(`https://api.sandbox.upprove.com${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ORCHESTR_SECRET_KEY}`,
'X-Signature': buildSignatureHeader(process.env.ORCHESTR_SIGNING_SECRET, path, body),
'Content-Type': 'application/json',
},
body, // send the exact string we signed
});
import hashlib
import hmac
import json
import time
import requests
def build_signature_header(signing_secret: str, path: str, body: str) -> str:
timestamp = str(int(time.time()))
signed = timestamp + path + (body or "")
v1 = hmac.new(signing_secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
return f"t={timestamp}, v1={v1}"
path = "/v1/checkout/sessions"
body = json.dumps(
{
"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"},
},
separators=(",", ":"),
)
resp = requests.post(
f"https://api.sandbox.upprove.com{path}",
data=body, # send the exact string we signed
headers={
"Authorization": f"Bearer {ORCHESTR_SECRET_KEY}",
"X-Signature": build_signature_header(ORCHESTR_SIGNING_SECRET, path, body),
"Content-Type": "application/json",
},
)
<?php
function build_signature_header(string $signingSecret, string $path, string $body): string {
$timestamp = (string) time();
$signed = $timestamp . $path . $body;
$v1 = hash_hmac('sha256', $signed, $signingSecret);
return "t={$timestamp}, v1={$v1}";
}
$path = '/v1/checkout/sessions';
$body = json_encode([
'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'],
], JSON_UNESCAPED_SLASHES);
$ch = curl_init("https://api.sandbox.upprove.com{$path}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body, // send the exact string we signed
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ORCHESTR_SECRET_KEY'),
'X-Signature: ' . build_signature_header(getenv('ORCHESTR_SIGNING_SECRET'), $path, $body),
'Content-Type: application/json',
],
]);
$response = curl_exec($ch);
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
static String buildSignatureHeader(String signingSecret, String path, String body) throws Exception {
long timestamp = Instant.now().getEpochSecond();
String signed = timestamp + path + (body == null ? "" : body);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String v1 = HexFormat.of().formatHex(mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)));
return "t=" + timestamp + ", v1=" + v1;
}
String path = "/v1/checkout/sessions";
String 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"}}""";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.sandbox.upprove.com" + path))
.header("Authorization", "Bearer " + System.getenv("ORCHESTR_SECRET_KEY"))
.header("X-Signature", buildSignatureHeader(System.getenv("ORCHESTR_SIGNING_SECRET"), path, body))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) // exact bytes
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
using System;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
static string BuildSignatureHeader(string signingSecret, string path, string body)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signed = timestamp + path + (body ?? "");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret));
var v1 = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed))).ToLowerInvariant();
return $"t={timestamp}, v1={v1}";
}
var path = "/v1/checkout/sessions";
var 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\"}}";
using var http = new HttpClient();
var content = new StringContent(body, Encoding.UTF8, "application/json"); // exact bytes
var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.sandbox.upprove.com{path}")
{
Content = content,
};
request.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("ORCHESTR_SECRET_KEY")}");
request.Headers.Add("X-Signature",
BuildSignatureHeader(Environment.GetEnvironmentVariable("ORCHESTR_SIGNING_SECRET"), path, body));
var response = await http.SendAsync(request);
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.
- cURL
- Node.js
- Python
- PHP
- Java
- C#
# 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"
const crypto = require('crypto');
function parseSignature(header) {
let timestamp;
const signatures = [];
for (const part of header.split(',')) {
const [k, v] = part.trim().split('=');
if (k === 't') timestamp = v;
else if (k === 'v1') signatures.push(v);
}
return {timestamp, signatures};
}
function verifySignature(signingSecret, header, rawBody) {
const {timestamp, signatures} = parseSignature(header);
if (!timestamp || signatures.length === 0) return false;
const expected = crypto
.createHmac('sha256', signingSecret)
.update(timestamp + rawBody, 'utf8')
.digest('hex');
const expectedBuf = Buffer.from(expected);
return signatures.some((s) => {
const sBuf = Buffer.from(s);
return sBuf.length === expectedBuf.length && crypto.timingSafeEqual(sBuf, expectedBuf);
});
}
import hashlib
import hmac
def parse_signature(header: str):
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
return timestamp, signatures
def verify_signature(signing_secret: str, header: str, raw_body: str) -> bool:
timestamp, signatures = parse_signature(header)
if not timestamp or not signatures:
return False
expected = hmac.new(
signing_secret.encode(), (timestamp + raw_body).encode(), hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
<?php
function parse_signature(string $header): array {
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) continue;
[$key, $value] = $kv;
if ($key === 't') $timestamp = $value;
elseif ($key === 'v1') $signatures[] = $value;
}
return [$timestamp, $signatures];
}
function verify_signature(string $signingSecret, string $header, string $rawBody): bool {
[$timestamp, $signatures] = parse_signature($header);
if ($timestamp === null || empty($signatures)) return false;
$expected = hash_hmac('sha256', $timestamp . $rawBody, $signingSecret);
foreach ($signatures as $s) {
if (hash_equals($expected, $s)) return true;
}
return false;
}
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
static boolean verifySignature(String signingSecret, String header, String rawBody) throws Exception {
String timestamp = null;
List<String> signatures = new ArrayList<>();
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) timestamp = kv[1];
else if (kv[0].equals("v1")) signatures.add(kv[1]);
}
if (timestamp == null || signatures.isEmpty()) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = java.util.HexFormat.of()
.formatHex(mac.doFinal((timestamp + rawBody).getBytes(StandardCharsets.UTF_8)))
.getBytes(StandardCharsets.UTF_8);
for (String s : signatures) {
if (MessageDigest.isEqual(expected, s.getBytes(StandardCharsets.UTF_8))) return true;
}
return false;
}
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
static bool VerifySignature(string signingSecret, string header, string rawBody)
{
string? timestamp = null;
var signatures = new List<string>();
foreach (var part in header.Split(','))
{
var kv = part.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t") timestamp = kv[1];
else if (kv[0] == "v1") signatures.Add(kv[1]);
}
if (timestamp is null || signatures.Count == 0) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(signingSecret));
var expected = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(timestamp + rawBody))).ToLowerInvariant();
var expectedBytes = Encoding.UTF8.GetBytes(expected);
foreach (var s in signatures)
{
var sBytes = Encoding.UTF8.GetBytes(s);
if (sBytes.Length == expectedBytes.Length &&
CryptographicOperations.FixedTimeEquals(sBytes, expectedBytes))
return true;
}
return false;
}
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
});
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.