Integrate
Authentication
Sign every Open Payment API request with your API key and HMAC-SHA256.
There are no bearer tokens. Each request carries your key ID and an HMAC-SHA256 signature of the request, computed with your API secret.
Headers
| Header | Value |
|---|---|
X-Key-Id |
Your API key ID, e.g. ok_test_9f3a… |
X-Timestamp |
Current Unix time in seconds. Must be within 5 minutes of HTP's clock. |
X-Nonce |
16–64 characters [A-Za-z0-9_-], new for every request. A nonce can be used once per key. |
Idempotency-Key |
8–64 characters [A-Za-z0-9_-]. Required on POST /payments, POST /payments/{id}/refunds, POST /payouts. Optional elsewhere. |
X-Signature |
The signature, see below. |
Content-Type |
application/json for requests with a body. |
Build the signature
text
string_to_sign = METHOD + "\n" +
PATH + "\n" +
X-Timestamp + "\n" +
X-Nonce + "\n" +
Idempotency-Key + "\n" +
hex(sha256(body))
X-Signature = base64( HMAC-SHA256( api_secret, string_to_sign ) )- METHOD — uppercase:
GET,POST. - PATH — the path including the query string, exactly as sent, without scheme and host:
/open/v1/orders?out_order_no=A1001&order_type=PAYMENT. - Idempotency-Key — an empty line when the header is not sent (the string then contains
\n\n). - body — the exact bytes you send. Serialize JSON once and send that same string. For
GETand empty bodies, hash the empty string:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. - hex is lowercase; base64 is standard (with
+,/and=padding). - The secret is used as UTF-8 bytes, as shown in the console.
Check your implementation
The Signature generator builds string_to_sign and X-Signature in your browser for any input, so you can diff it against your code.
Worked example
text
METHOD POST
PATH /open/v1/payments
X-Timestamp 1700000000
X-Nonce nonce-0123456789
Idempotency-Key idem-1234
body {"a":1}text
POST
/open/v1/payments
1700000000
nonce-0123456789
idem-1234
015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862Client code
Each snippet is a complete, dependency-light client you can copy.
Node.js
import crypto from 'node:crypto';
const API_BASE = process.env.HTP_API_BASE ?? '';
const KEY_ID = process.env.HTP_KEY_ID;
const SECRET = process.env.HTP_API_SECRET;
export class HtpError extends Error {
constructor(status, body) {
super(body.message);
this.status = status;
this.code = body.error_code;
this.traceId = body.trace_id;
}
}
export async function htp(method, path, body, idempotencyKey = '') {
const raw = body === undefined ? '' : JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomBytes(18).toString('base64url');
const bodyHash = crypto.createHash('sha256').update(raw).digest('hex');
const stringToSign = [method, path, timestamp, nonce, idempotencyKey, bodyHash].join('\n');
const signature = crypto.createHmac('sha256', SECRET).update(stringToSign).digest('base64');
const headers = {
'Content-Type': 'application/json',
'X-Key-Id': KEY_ID,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
};
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const res = await fetch(API_BASE + path, { method, headers, body: raw || undefined });
const json = await res.json();
if (!res.ok) throw new HtpError(res.status, json);
return json.data;
}Python
import base64, hashlib, hmac, json, os, secrets, time
import requests
API_BASE = os.environ.get("HTP_API_BASE", "")
KEY_ID = os.environ["HTP_KEY_ID"]
SECRET = os.environ["HTP_API_SECRET"].encode()
class HtpError(Exception):
def __init__(self, status, body):
super().__init__(body.get("message"))
self.status = status
self.code = body.get("error_code")
self.trace_id = body.get("trace_id")
def htp(method, path, body=None, idempotency_key=""):
raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
timestamp = str(int(time.time()))
nonce = secrets.token_urlsafe(18)
body_hash = hashlib.sha256(raw).hexdigest()
string_to_sign = "\n".join([method, path, timestamp, nonce, idempotency_key, body_hash])
signature = base64.b64encode(hmac.new(SECRET, string_to_sign.encode(), hashlib.sha256).digest()).decode()
headers = {
"Content-Type": "application/json",
"X-Key-Id": KEY_ID,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": signature,
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
r = requests.request(method, API_BASE + path, headers=headers, data=raw or None, timeout=15)
payload = r.json()
if not r.ok:
raise HtpError(r.status_code, payload)
return payload["data"]Go
package htp
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
type Client struct {
BaseURL, KeyID, Secret string
HTTP *http.Client
}
type Error struct {
Status int
Code string `json:"error_code"`
Message string `json:"message"`
TraceID string `json:"trace_id"`
}
func (e *Error) Error() string { return fmt.Sprintf("%s: %s (trace %s)", e.Code, e.Message, e.TraceID) }
func (c *Client) Do(ctx context.Context, method, path string, body any, idempotencyKey string, out any) error {
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return err
}
}
nb := make([]byte, 18)
if _, err := rand.Read(nb); err != nil {
return err
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
nonce := base64.RawURLEncoding.EncodeToString(nb)
sum := sha256.Sum256(raw)
stringToSign := strings.Join([]string{method, path, timestamp, nonce, idempotencyKey, hex.EncodeToString(sum[:])}, "\n")
mac := hmac.New(sha256.New, []byte(c.Secret))
mac.Write([]byte(stringToSign))
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Key-Id", c.KeyID)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var envelope struct {
Data json.RawMessage `json:"data"`
}
if res.StatusCode >= 300 {
e := &Error{Status: res.StatusCode}
_ = json.NewDecoder(res.Body).Decode(e)
return e
}
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
return err
}
return json.Unmarshal(envelope.Data, out)
}PHP
<?php
const HTP_API_BASE = '';
function htp(string $method, string $path, ?array $body = null, string $idempotencyKey = ''): array
{
$raw = $body === null ? '' : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$timestamp = (string) time();
$nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
$stringToSign = implode("\n", [$method, $path, $timestamp, $nonce, $idempotencyKey, hash('sha256', $raw)]);
$signature = base64_encode(hash_hmac('sha256', $stringToSign, getenv('HTP_API_SECRET'), true));
$headers = [
'Content-Type: application/json',
'X-Key-Id: ' . getenv('HTP_KEY_ID'),
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
'X-Signature: ' . $signature,
];
if ($idempotencyKey !== '') {
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init(HTP_API_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
if ($raw !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);
}
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 300) {
throw new RuntimeException("{$response['error_code']}: {$response['message']} (trace {$response['trace_id']})", $status);
}
return $response['data'];
}Java
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.Instant;
import java.util.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class HtpClient {
private final String baseUrl, keyId, secret;
private final HttpClient http = HttpClient.newHttpClient();
private final SecureRandom random = new SecureRandom();
public HtpClient(String baseUrl, String keyId, String secret) {
this.baseUrl = baseUrl; this.keyId = keyId; this.secret = secret;
}
/** body: JSON string or null. Returns the raw response JSON. */
public HttpResponse<String> send(String method, String path, String body, String idempotencyKey) throws Exception {
String raw = body == null ? "" : body;
String idem = idempotencyKey == null ? "" : idempotencyKey;
String timestamp = String.valueOf(Instant.now().getEpochSecond());
byte[] nb = new byte[18];
random.nextBytes(nb);
String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(nb);
String bodyHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8)));
String stringToSign = String.join("\n", method, path, timestamp, nonce, idem, bodyHash);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(baseUrl + path))
.header("Content-Type", "application/json")
.header("X-Key-Id", keyId)
.header("X-Timestamp", timestamp)
.header("X-Nonce", nonce)
.header("X-Signature", signature)
.method(method, raw.isEmpty() ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(raw));
if (!idem.isEmpty()) b.header("Idempotency-Key", idem);
return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
}Common signature mistakes
| Symptom | Cause |
|---|---|
SIGNATURE_INVALID on GET with query |
PATH signed without the query string, or with differently encoded parameters than sent |
| Works for GET, fails for POST | Body re-serialized after signing (key order, spaces, Unicode escaping changed) |
Fails only on /close |
/close has no body — hash the empty string, do not send {} unless you also sign {} |
X-Timestamp is outside the allowed window |
Milliseconds instead of seconds, or server clock drift — use NTP |
X-Nonce was already used |
Nonce reused on a retry. Retries need a new nonce and signature, but the same Idempotency-Key |
Idempotency-Key … is required |
Missing on a create endpoint, or shorter than 8 characters |
| Fails after creating a new key | Old key is valid for 24 h; check X-Key-Id matches the secret you sign with |
Keep the secret on your server
Anyone with the API secret can create refunds and payouts from your wallet. Never put it in a web page, mobile app, repository or log. If it leaks, create a new key and revoke the old one in the console.