Skip to content

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-URL restriction: 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 400 X-Callback-URL must match the firm's registered webhook host. If no callback URL can be resolved at all, the request fails with 400 Callback 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}\n{PATH}\n{BODY}\n{TIMESTAMP}\n{NONCE}
  • HTTP_METHOD: POST or GET (uppercase)
  • PATH: e.g. /paymentapi/deposit (path without query string)
  • BODY: the raw JSON body (sign the exact bytes you send); for GET requests with no body, an empty string — the message then contains an empty BODY line
  • TIMESTAMP, NONCE: same values as the headers
X-Signature = HMAC_SHA256(secret_key, message)  →  hex

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 BODY you 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):

{ "status": false, "msg": "Bu tutar icin uygun hesap yok" }

Duplicate transactionid (HTTP 200):

{ "status": false, "msg": "Bu islem numarasi zaten kullanilmis" }

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

{ "status": true, "msg": "Çekim talebiniz alındı. Birazdan işleme alınacaktır" }

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"
  }
}

data is 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_*). tcid is masked (1324***10). Withdrawals additionally include bankid and iban.

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:

  1. 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.
  2. X-Callback-URL header — per-transaction override (same-host restriction, see section 4).
  3. 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

{
  "transid": "TX-DEP-001",
  "amount": "1000.00",
  "status": "approve",
  "hashcode": "a1b2c3d4e5..."
}
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 hashcode is computed over the exact amount string sent (always 2 decimals). Build the verification message from the values received verbatim — do not reformat them.

6.2 Verifying the hashcode

Default scheme:

message  = "{transid},{amount},{status}"
hashcode = HMAC-SHA256(secret_key, message)  →  hex

The signature scheme can be customized per account if needed (e.g. sha256 algorithm with the secret,transid,amount,status format). 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 transid may be re-delivered until you respond HTTP 200 (retries) — always with the same status. Deduplicate on transid; 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:
{ "status": false, "msg": "Rate limit exceeded. Please try again later." }

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/withdraw with 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-Nonce must be unique per request — a reused nonce is rejected with 401 Nonce replay detected. The examples generate a random 32-hex-char nonce each call.
  • Clock skew: X-Timestamp must be within ±5 minutes of server time; keep your server clock NTP-synced.
  • To send a per-transaction X-Callback-URL header, remember the same-host restriction in section 4.