HorizonPay — Integration Guide¶
1. Overview¶
HorizonPay accepts deposit and withdraw requests from your site, routes them to a payment channel/account, and notifies your callback URL with a signed message once the result is final.
| Direction | Caller | Description |
|---|---|---|
| Request | You → HorizonPay | HMAC-signed calls to /paymentapi/* |
| Callback | HorizonPay → You | Signed POST to your callback URL on approve/reject |
Deposit flow:
1. User starts a deposit on your site
2. You ──POST /paymentapi/deposit──▶ HorizonPay (HMAC signed)
3. HorizonPay returns a bank account (bank_name, iban, holder_name)
4. User pays that account
5. On approval HorizonPay ──POST {callback_url}──▶ You (hashcode signed)
6. You verify hashcode, credit the user, return HTTP 200
Withdraw is the same shape: create via /paymentapi/withdraw, result delivered by callback.
2. Base URL¶
| Environment | Base URL |
|---|---|
| Production | https://api.horizonpayg.com |
A separate test (UAT) URL, if available for your integration, is shared at onboarding. Examples below use the production base URL.
3. Credentials¶
| Value | Description |
|---|---|
| API Key | Sent in the X-API-Key header. Identifies your account. |
| Secret Key | Used to sign requests and to verify the callback hashcode. Never expose it client-side. |
The secret key is shared once at creation. If lost, it must be regenerated (the old one stops working).
4. Authentication (HMAC-SHA256)¶
Every request to /paymentapi/* requires these headers:
| Header | Description | Constraint |
|---|---|---|
X-API-Key |
Your API key | Must be active |
X-Signature |
HMAC-SHA256 signature | Hex (lowercase) |
X-Timestamp |
Unix timestamp (seconds) | Within ±5 minutes of server time |
X-Nonce |
Per-request unique value | 8–64 chars, never reused |
X-Callback-URL |
Per-transaction callback override | Optional — must point to the same scheme + host (+ port) as the webhook URL registered on your account |
Content-Type |
application/json |
— |
X-Callback-URLrestriction: the header is only accepted when it targets the same scheme + host (+ port) as your registered webhook URL — you can change the path, not the destination. A header pointing anywhere else (or sent while no webhook is registered on your account) is rejected with 400X-Callback-URL must match the firm's registered webhook host. If no callback URL can be resolved at all, the request fails with 400Callback URL not configured on provider.Platform integrations: if your payment platform (e.g. a turnkey/aggregator provider) has a central callback URL registered with HorizonPay, all results are delivered there — this header and your account webhook are not used. See section 6.
Signature¶
Message to sign (lines joined with literal \n newlines):
HTTP_METHOD:POSTorGET(uppercase)PATH: e.g./paymentapi/deposit(path without query string)BODY: the raw JSON body (sign the exact bytes you send); forGETrequests with no body, an empty string — the message then contains an emptyBODYlineTIMESTAMP,NONCE: same values as the headers
Example (bash)¶
API_KEY="<api-key>"
SECRET_KEY="<secret-key>"
TIMESTAMP=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY='{"amount":"1000","bnk":"1","first_name":"Ali","last_name":"Yilmaz","username":"aliyilmaz","userid":"user001","transactionid":"TX-DEP-001","method":"fast"}'
MESSAGE="POST\n/paymentapi/deposit\n${BODY}\n${TIMESTAMP}\n${NONCE}"
SIGNATURE=$(printf "%b" "${MESSAGE}" | openssl dgst -sha256 -hmac "${SECRET_KEY}" | awk '{print $2}')
curl -X POST "https://api.horizonpayg.com/paymentapi/deposit" \
-H "Content-Type: application/json" \
-H "X-API-Key: ${API_KEY}" \
-H "X-Signature: ${SIGNATURE}" \
-H "X-Timestamp: ${TIMESTAMP}" \
-H "X-Nonce: ${NONCE}" \
-H "X-Callback-URL: https://yoursite.com/horizonpay/callback" \
-d "${BODY}"
Common mistake: the
BODYyou sign and the body you send must be byte-identical. Do not re-serialize the JSON — sign and send the same string.
Complete client implementations (PHP, Node.js, Python) are in section 10.
5. Endpoints¶
5.1 POST /paymentapi/deposit¶
Creates a deposit; routed to a payment account automatically.
Request:
{
"amount": "1000",
"bnk": "1",
"tcid": "12345678901",
"first_name": "Ali",
"last_name": "Yilmaz",
"username": "aliyilmaz",
"userid": "user001",
"transactionid": "TX-DEP-001",
"method": "fast"
}
| Field | Type | Required | Constraint |
|---|---|---|---|
amount |
string | Yes | Numeric, > 0 |
bnk |
string | Yes | 1–20 chars (payment channel / bank id) |
tcid |
string | No | National ID — optional, no format check |
first_name |
string | Yes | 2–50 chars, letters only |
last_name |
string | Yes | 2–50 chars, letters only |
username |
string | Yes | 3–30 chars, alphanumeric |
userid |
string | Yes | 1–50 chars |
transactionid |
string | Yes | 1–100 chars, unique (idempotency key) |
method |
string | No | fast, eft, or havale (default fast) |
Success (200):
{
"status": true,
"msg": "Deposit received",
"bank_name": "Ziraat Bankasi",
"holder_name": "Mehmet Kaya",
"iban": "TR330006100519786457841326"
}
Show these account details to the user; payment goes to this account.
No account available (HTTP 200):
Duplicate transactionid (HTTP 200):
5.2 POST /paymentapi/withdraw¶
Creates a withdrawal.
Request:
{
"bankid": "1",
"amount": "5000",
"first_name": "Ali",
"last_name": "Yilmaz",
"tcid": "12345678901",
"iban": "TR330006100519786457841326",
"transactionid": "TX-WDR-001",
"userid": "user001",
"username": "aliyilmaz"
}
| Field | Type | Required | Constraint |
|---|---|---|---|
bankid |
string | Yes | 1–20 chars |
amount |
string | Yes | Numeric, > 0 |
first_name |
string | Yes | 2–50 chars, letters only |
last_name |
string | Yes | 2–50 chars, letters only |
tcid |
string | No | Optional |
iban |
string | Yes | Exactly 26 chars |
transactionid |
string | Yes | 1–100 chars, unique |
userid |
string | Yes | 1–50 chars |
username |
string | Yes | 3–30 chars, alphanumeric |
Success (200):
5.3 GET /paymentapi/getbyid/{id}¶
Query status by internal transaction ID. You only see your own transactions.
Response (200):
{
"status": true,
"msg": "pending",
"data": {
"id": 1,
"transactionid": "TX-DEP-001",
"type": "deposit",
"status": "pending",
"amount": 1000.00,
"method": "fast",
"first_name": "Ali",
"last_name": "Yilmaz",
"username": "aliyilmaz",
"userid": "user001",
"tcid": "1234***01",
"assigned_bank_name": "Ziraat Bankasi",
"assigned_holder_name": "Mehmet Kaya",
"assigned_iban": "TR330006100519786457841326",
"created_at": "2026-03-30T12:00:00Z",
"updated_at": "2026-03-30T12:00:00Z"
}
}
datais a brand-scoped slim view. Internal fields are not returned (firm_id,merchant_id,callback_url,processed_by,handling_by,amount_edited_by,original_amount,decision_note*,refund_*).tcidis masked (1324***10). Withdrawals additionally includebankidandiban.
5.4 GET /paymentapi/getbyexternalid/{transactionid}¶
Query by the transactionid you submitted. Same response format as getbyid.
These endpoints are for status checks / reconciliation — they do not replace the callback. The authoritative result is delivered by callback.
6. Callbacks¶
When a transaction is approved or rejected, HorizonPay sends an asynchronous POST to the callback URL. The destination is resolved in this priority order:
- Platform central callback URL — if your payment platform (turnkey/aggregator provider) has one registered with HorizonPay, all results go there, signed with the platform's secret and signature scheme.
X-Callback-URLheader — per-transaction override (same-host restriction, see section 4).- Account webhook URL — the default registered on your account.
In cases 2 and 3 the callback is signed with your account's secret key as described below. In case 1 the platform verifies and relays the result to you through its own channels — the rest of this section then applies to the platform, not to your site.
6.1 Payload¶
| Field | Description |
|---|---|
transid |
The transactionid you submitted |
amount |
Amount as a string, always 2 decimals (e.g. 1000.00) |
status |
approve or reject |
hashcode |
Signature — verify as below |
The
hashcodeis computed over the exactamountstring sent (always 2 decimals). Build the verification message from the values received verbatim — do not reformat them.
6.2 Verifying the hashcode¶
Default scheme:
The signature scheme can be customized per account if needed (e.g.
sha256algorithm with thesecret,transid,amount,statusformat). The active scheme is confirmed at onboarding. The examples below use the default (HMAC-SHA256,transid,amount,status).
PHP:
$message = $_POST['transid'] . ',' . $_POST['amount'] . ',' . $_POST['status'];
$expected = hash_hmac('sha256', $message, $secretKey);
if (!hash_equals($expected, $_POST['hashcode'])) {
http_response_code(400);
exit('invalid signature');
}
// safe: apply the transaction, then return 200
Node.js:
const crypto = require('crypto')
const message = `${req.body.transid},${req.body.amount},${req.body.status}`
const expected = crypto.createHmac('sha256', SECRET_KEY).update(message).digest('hex')
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.body.hashcode))
if (!ok) return res.status(400).send('invalid signature')
// apply the transaction, then:
res.status(200).json({ status: true })
Python:
import hmac, hashlib
message = f"{transid},{amount},{status}".encode()
expected = hmac.new(secret_key.encode(), message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, hashcode):
return ("invalid signature", 400)
# apply the transaction, then return 200
6.3 Your response¶
- Return HTTP 200. Any non-2xx (3xx/4xx/5xx) is treated as failure and retried.
- Response body is free-form, e.g.
{"status": true, "msg": "OK"}.
6.4 Retry policy¶
| Property | Value |
|---|---|
| Max attempts | 3 |
| Backoff (exponential) | 1s, 2s, 4s |
| HTTP timeout | 10 seconds |
| Durability | Stored in an outbox; pending callbacks are re-processed even after a restart |
6.5 Status mapping¶
| Transition | Callback status |
|---|---|
| pending → approved | approve |
| pending → rejected | reject |
A callback is sent only once, on the transaction's first and only result (
pending → approved/rejected). Later back-office status corrections on the panel (e.g.approved → rejected) do not emit a callback — treat the first callback you receive as final and irreversible.Idempotency: the callback for a given
transidmay be re-delivered until you respond HTTP 200 (retries) — always with the samestatus. Deduplicate ontransid; never apply the same result twice.
7. Transaction Statuses¶
| Status | Description |
|---|---|
pending |
Created, awaiting result |
approved |
Approved (callback approve) |
rejected |
Rejected/cancelled (callback reject) |
8. Rate Limiting¶
- Default: 100 requests/minute (token bucket, per API key or IP).
- On limit, HTTP 429:
9. Error Codes¶
| HTTP | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad request / validation error / invalid callback URL |
| 401 | Authentication failed (HMAC) |
| 403 | Insufficient permission |
| 404 | Not found |
| 413 | Request too large (>1 MB) |
| 429 | Rate limit exceeded |
| 500 | Server error |
All /paymentapi errors share the response envelope { "status": false, "msg": "<reason>" } (the same shape as a success, with status: false) — always branch on the status field, not the HTTP code alone.
9.1 Authentication failures (401)¶
AuthMiddleware rejects the request before it reaches any endpoint. Each returns HTTP 401 with { "status": false, "msg": … }:
msg |
Cause & fix |
|---|---|
Authentication required: missing headers |
One of X-API-Key / X-Signature / X-Timestamp / X-Nonce is absent |
Invalid API key |
X-API-Key doesn't match an active account |
Invalid signature |
HMAC mismatch — recompute the signature per section 4 and make sure the body you sent is byte-identical to the body you signed |
Invalid timestamp: … |
X-Timestamp is outside the accepted skew window (too old or in the future) — send the current Unix time in seconds and sync your clock |
Invalid nonce: … |
X-Nonce is malformed or replayed (nonce already used (replay detected)) — generate a fresh random nonce for every request |
9.2 Request errors (4xx)¶
Returned by the endpoint after auth passes, same { "status": false, "msg": … } envelope:
| HTTP | msg (examples) |
|---|---|
| 400 | Invalid request format, Validation failed (amount/name/username/etc.), X-Callback-URL must match the firm's registered webhook host, Callback URL not configured on provider, Invalid callback URL (SSRF guard) |
| 413 | Request body larger than 1 MB |
| 429 | Rate limit exceeded. Please try again later. |
A duplicate transactionid is not an HTTP error: the API returns 200 with { "status": false, "msg": "Bu islem numarasi zaten kullanilmis" }. Always check the status field, not just the HTTP code.
10. Sample Client Code¶
Each example signs and sends a request, covering both POST (JSON body) and GET (empty body — the BODY line in the signed message is an empty string). The same helper works for every /paymentapi/* endpoint; only the path and payload change. Callback verification examples are in section 6.2.
10.1 PHP¶
<?php
const BASE_URL = 'https://api.horizonpayg.com';
const API_KEY = '<api-key>';
const SECRET_KEY = '<secret-key>';
function horizonpayRequest(string $method, string $path, ?array $payload = null): array
{
$body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
// METHOD \n PATH \n BODY \n TIMESTAMP \n NONCE
$message = implode("\n", [$method, $path, $body, $timestamp, $nonce]);
$signature = hash_hmac('sha256', $message, SECRET_KEY);
$ch = curl_init(BASE_URL . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . API_KEY,
'X-Signature: ' . $signature,
'X-Timestamp: ' . $timestamp,
'X-Nonce: ' . $nonce,
],
]);
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body); // send the exact string you signed
}
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
// Deposit
$deposit = horizonpayRequest('POST', '/paymentapi/deposit', [
'amount' => '1000',
'bnk' => '1',
'first_name' => 'Ali',
'last_name' => 'Yilmaz',
'username' => 'aliyilmaz',
'userid' => 'user001',
'transactionid' => 'TX-DEP-001',
'method' => 'fast',
]);
if ($deposit['status']) {
// show $deposit['bank_name'], $deposit['holder_name'], $deposit['iban'] to the user
} else {
// e.g. "Bu tutar icin uygun hesap yok" or "Bu islem numarasi zaten kullanilmis"
}
// Status check (reconciliation)
$status = horizonpayRequest('GET', '/paymentapi/getbyexternalid/TX-DEP-001');
10.2 Node.js (18+)¶
const crypto = require('crypto')
const BASE_URL = 'https://api.horizonpayg.com'
const API_KEY = '<api-key>'
const SECRET_KEY = '<secret-key>'
async function horizonpayRequest(method, path, payload) {
const body = payload ? JSON.stringify(payload) : ''
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = crypto.randomBytes(16).toString('hex')
// METHOD \n PATH \n BODY \n TIMESTAMP \n NONCE
const message = [method, path, body, timestamp, nonce].join('\n')
const signature = crypto.createHmac('sha256', SECRET_KEY).update(message).digest('hex')
const res = await fetch(BASE_URL + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'X-Signature': signature,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
},
body: body || undefined, // send the exact string you signed
})
return res.json()
}
// Deposit
const deposit = await horizonpayRequest('POST', '/paymentapi/deposit', {
amount: '1000',
bnk: '1',
first_name: 'Ali',
last_name: 'Yilmaz',
username: 'aliyilmaz',
userid: 'user001',
transactionid: 'TX-DEP-001',
method: 'fast',
})
// Status check (reconciliation)
const status = await horizonpayRequest('GET', '/paymentapi/getbyexternalid/TX-DEP-001')
10.3 Python¶
import hashlib
import hmac
import json
import secrets
import time
import requests
BASE_URL = "https://api.horizonpayg.com"
API_KEY = "<api-key>"
SECRET_KEY = "<secret-key>"
def horizonpay_request(method: str, path: str, payload: dict | None = None) -> dict:
body = "" if payload is None else json.dumps(payload, separators=(",", ":"))
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
# METHOD \n PATH \n BODY \n TIMESTAMP \n NONCE
message = "\n".join([method, path, body, timestamp, nonce])
signature = hmac.new(SECRET_KEY.encode(), message.encode(), hashlib.sha256).hexdigest()
resp = requests.request(
method,
BASE_URL + path,
headers={
"Content-Type": "application/json",
"X-API-Key": API_KEY,
"X-Signature": signature,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
},
data=body or None, # send the exact string you signed
timeout=30,
)
return resp.json()
# Deposit
deposit = horizonpay_request("POST", "/paymentapi/deposit", {
"amount": "1000",
"bnk": "1",
"first_name": "Ali",
"last_name": "Yilmaz",
"username": "aliyilmaz",
"userid": "user001",
"transactionid": "TX-DEP-001",
"method": "fast",
})
# Status check (reconciliation)
status = horizonpay_request("GET", "/paymentapi/getbyexternalid/TX-DEP-001")
Notes¶
- Withdraw uses the same helper:
POST /paymentapi/withdrawwith the fields from section 5.2. - Sign exactly what you send. All three helpers serialize the payload once and use the same string for both the signature and the request body.
X-Noncemust be unique per request — a reused nonce is rejected with 401Nonce replay detected. The examples generate a random 32-hex-char nonce each call.- Clock skew:
X-Timestampmust be within ±5 minutes of server time; keep your server clock NTP-synced. - To send a per-transaction
X-Callback-URLheader, remember the same-host restriction in section 4.