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

Verification test vector

Use this fixed example to verify an implementation without sending a request. The signing secret is a dummy value and must not be used in production.

Signing secret: test-signing-secret
Timestamp: 1785259200
Method: POST
Target: /api/v1/deposits
Raw 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":{}}
Body SHA-256: 2425e770e66748930322a5b7279295466585cafa53763332ec15a10175bb9270

The exact signature input is:

1785259200
POST
/api/v1/deposits
2425e770e66748930322a5b7279295466585cafa53763332ec15a10175bb9270

The expected signature is:

v1=2FWMEr0xcAhqAzUf-BI51IwMYXoFVuS3i8kVBknXbUk

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}`,
});

PHP

This standalone example uses PHP's cURL extension and requires no framework or third-party package:

<?php

declare(strict_types=1);

$apiKey = getenv("OMNIPAY_API_KEY");
$secret = getenv("OMNIPAY_SIGNING_SECRET");
if ($apiKey === false || $apiKey === "" || $secret === false || $secret === "") {
    throw new RuntimeException("Missing OmniPay credentials");
}

$url = "https://omnipay.page/api/v1/deposits";
$path = "/api/v1/deposits";
$body = json_encode([
    "amount" => "500.00",
    "payment_method" => "bkash_merchant",
    "redirect_url" => "https://merchant.example/return",
    "merchant_id" => "order-123",
    "checkout_mode" => "h2h",
    "payer" => ["phone" => "01712345678"],
    "metadata" => (object) [],
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);

$timestamp = (string) time();
$bodyHash = hash("sha256", $body);
$input = implode("\n", [$timestamp, "POST", $path, $bodyHash]);

// Use the secret exactly as provided. Do not Base64-decode it.
$digest = hash_hmac("sha256", $input, $secret, true);
$signature = "v1=" . rtrim(
    strtr(base64_encode($digest), "+/", "-_"),
    "=",
);

$curl = curl_init($url);
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
    // Send the same string that was hashed above; do not encode it again.
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer {$apiKey}",
        "Content-Type: application/json",
        "Accept: application/json",
        "X-Omni-Timestamp: {$timestamp}",
        "X-Omni-Signature: {$signature}",
    ],
]);

$response = curl_exec($curl);
if ($response === false) {
    $message = curl_error($curl);
    curl_close($curl);
    throw new RuntimeException($message);
}

$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($status < 200 || $status >= 300) {
    throw new RuntimeException("OmniPay {$status}: {$response}");
}

$deposit = json_decode($response, true, 512, JSON_THROW_ON_ERROR);

Use "\n", not PHP_EOL, when building the signature input. The final HMAC must be binary before Base64URL encoding, which is why hash_hmac receives true as its fourth argument.

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
Signature is 64 hexadecimal charactersRequest the binary HMAC, encode it as unpadded Base64URL, and prefix it with v1=
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