Request signing
Generate the OmniPay H2H HMAC signature byte-for-byte.
Every H2H request needs the bearer API key plus two signature headers:
Authorization: Bearer <api-key>
X-Omni-Timestamp: 1785259200
X-Omni-Signature: v1=<unpadded-base64url-digest>Timestamps are Unix seconds and must be within five minutes of OmniPay's clock. Synchronize production hosts with NTP.
Canonical input
Join these four values with a single newline and no trailing newline:
<timestamp>
<UPPERCASE HTTP METHOD>
<path and canonically sorted query string>
<lowercase hex SHA-256 of the exact raw request body>For a POST to https://omnipay.page/api/v1/deposits, the target is
/api/v1/deposits. The /api prefix is part of the path and must be signed.
Query entries are sorted first by key and then by value before normal URL query encoding. Repeated keys are retained. For example:
/api/example?z=2&a=3&a=1becomes:
/api/example?a=1&a=3&z=2Hash the body exactly as transmitted. JSON key order, whitespace, and newline bytes matter because OmniPay verifies the raw bytes. Serialize once, use that same string for the hash, then write those same bytes to the HTTP request.
For GET requests, the body is the empty string. Its SHA-256 is:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855TypeScript
import { createHash, createHmac } from "node:crypto";
function canonicalTarget(input: string): string {
const url = new URL(input);
const entries = [...url.searchParams.entries()].sort(
([keyA, valueA], [keyB, valueB]) =>
keyA === keyB
? valueA.localeCompare(valueB)
: keyA.localeCompare(keyB),
);
const query = new URLSearchParams(entries).toString();
return `${url.pathname}${query ? `?${query}` : ""}`;
}
function signRequest(args: {
secret: string;
timestamp: string;
method: string;
url: string;
rawBody: string;
}): string {
const bodyHash = createHash("sha256")
.update(args.rawBody)
.digest("hex");
const input = [
args.timestamp,
args.method.toUpperCase(),
canonicalTarget(args.url),
bodyHash,
].join("\n");
const digest = createHmac("sha256", args.secret)
.update(input)
.digest("base64url");
return `v1=${digest}`;
}
async function signedRequest<T>(args: {
method: "GET" | "POST";
url: string;
body?: unknown;
}): Promise<T> {
const apiKey = process.env.OMNIPAY_API_KEY;
const secret = process.env.OMNIPAY_SIGNING_SECRET;
if (!apiKey || !secret) {
throw new Error("Missing OmniPay credentials");
}
// Serialize once. The same bytes are hashed and sent.
const rawBody = args.body === undefined ? "" : JSON.stringify(args.body);
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = signRequest({
secret,
timestamp,
method: args.method,
url: args.url,
rawBody,
});
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
"X-Omni-Timestamp": timestamp,
"X-Omni-Signature": signature,
};
if (args.body !== undefined) headers["Content-Type"] = "application/json";
const response = await fetch(args.url, {
method: args.method,
headers,
body: args.body === undefined ? undefined : rawBody,
});
const responseText = await response.text();
const data = responseText ? JSON.parse(responseText) : null;
if (!response.ok) {
const requestId = response.headers.get("X-Request-Id") ?? "unknown";
throw new Error(
`OmniPay ${response.status} [request ${requestId}]: ${responseText}`,
);
}
return data as T;
}
const deposit = await signedRequest<{ id: string }>({
method: "POST",
url: "https://omnipay.page/api/v1/deposits",
body: {
amount: "500.00",
payment_method: "bkash_merchant",
redirect_url: "https://merchant.example/return",
merchant_id: "order-123",
checkout_mode: "h2h",
payer: { phone: "01712345678" },
metadata: {},
},
});
// GET requests use an empty body automatically.
const current = await signedRequest<Record<string, unknown>>({
method: "GET",
url: `https://omnipay.page/api/v1/deposits/${deposit.id}`,
});Python
This example uses requests:
python -m pip install requestsimport base64
import hashlib
import hmac
import json
import os
import time
import requests
from urllib.parse import parse_qsl, urlencode, urlsplit
def canonical_target(url: str) -> str:
parsed = urlsplit(url)
query = urlencode(sorted(parse_qsl(parsed.query, keep_blank_values=True)))
return parsed.path + (f"?{query}" if query else "")
def sign_request(secret: str, timestamp: str, method: str, url: str, body: str) -> str:
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
signing_input = "\n".join([
timestamp,
method.upper(),
canonical_target(url),
body_hash,
])
digest = hmac.new(
secret.encode("utf-8"),
signing_input.encode("utf-8"),
hashlib.sha256,
).digest()
encoded = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return f"v1={encoded}"
body = json.dumps(
{
"amount": "500.00",
"payment_method": "bkash_merchant",
"redirect_url": "https://merchant.example/return",
"merchant_id": "order-123",
"checkout_mode": "h2h",
"payer": {"phone": "01712345678"},
"metadata": {},
},
separators=(",", ":"),
)
timestamp = str(int(time.time()))
url = "https://omnipay.page/api/v1/deposits"
signature = sign_request(
os.environ["OMNIPAY_SIGNING_SECRET"],
timestamp,
"POST",
url,
body,
)
response = requests.post(
url,
data=body.encode("utf-8"),
headers={
"Authorization": f"Bearer {os.environ['OMNIPAY_API_KEY']}",
"Content-Type": "application/json",
"X-Omni-Timestamp": timestamp,
"X-Omni-Signature": signature,
},
timeout=30,
)
response.raise_for_status()
deposit = response.json()Pass the serialized string through data, not json=, so the HTTP client does
not serialize the object a second time after you compute the signature.
Common signing mistakes
| Symptom | Check |
|---|---|
Every request returns invalid_signature | Include the /api path prefix and hash the exact transmitted bytes |
| GET signatures fail | Hash the empty string and do not send {} as the body |
| Requests fail intermittently | Generate the timestamp immediately before sending and synchronize the host clock |
| Requests with query parameters fail | Sort by key, then value, and retain repeated keys |
| A retry fails after changing JSON | Re-serialize the final body and compute a new timestamp and signature |
Failure codes
Signature failures return HTTP 401 with one of these messages:
| Reason | Meaning |
|---|---|
missing_signature | One or both signature headers are missing |
invalid_timestamp | Timestamp is not whole Unix seconds |
stale_signature | Timestamp differs from server time by more than five minutes |
invalid_signature | The computed digest does not match |
Do not retry invalid_signature unchanged. Recompute from the bytes that will
actually be sent. For stale_signature, correct clock skew, create a new
timestamp, and recompute the signature.
Secret rotation
Generate a new H2H signing secret from the developer dashboard. Rotation invalidates signatures made with the old secret immediately, so coordinate deployment of the new value. Never log the secret, signature input, or complete Authorization header in production.