OmniPayDocs
H2H checkout

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=1

becomes:

/api/example?a=1&a=3&z=2

Hash 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:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

TypeScript

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 requests
import 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

SymptomCheck
Every request returns invalid_signatureInclude the /api path prefix and hash the exact transmitted bytes
GET signatures failHash the empty string and do not send {} as the body
Requests fail intermittentlyGenerate the timestamp immediately before sending and synchronize the host clock
Requests with query parameters failSort by key, then value, and retain repeated keys
A retry fails after changing JSONRe-serialize the final body and compute a new timestamp and signature

Failure codes

Signature failures return HTTP 401 with one of these messages:

ReasonMeaning
missing_signatureOne or both signature headers are missing
invalid_timestampTimestamp is not whole Unix seconds
stale_signatureTimestamp differs from server time by more than five minutes
invalid_signatureThe 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.

On this page