PayHub Integration API
For a merchant's own system: create an order, get a payment link, and be told when it is paid.
Base URL — https://api.payhub.am/api/integration (dev: https://dev.api.payhub.am/api/integration).
1. Read this first: every response is HTTP 200
PayHub answers 200 OK even when your request failed. The real outcome is in the body:
{ "hasError": false, "errors": [], "data": { … } }
{ "hasError": true,
"data": null,
"errors": [{ "statusCode": 409, "code": "idempotency.key_reused",
"field": "Idempotency-Key", "description": "…" }] }
Branch on hasError, never on the HTTP status. A client that checks the status code alone will read a refusal as a success — it will believe an order was created when it was not.
The single exception is 401, which is a real 401 with no body: your key is missing, unknown or revoked.
r = requests.post(url, json=body, headers=headers) # r.status_code is 200 even on failure
payload = r.json()
if payload["hasError"]:
err = payload["errors"][0]
raise PayHubError(err["code"], err["description"], err["statusCode"])
order = payload["data"]
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
if (res.status === 401) throw new Error("PayHub: key missing, unknown or revoked");
const payload = await res.json();
if (payload.hasError) {
const [err] = payload.errors;
throw new Error(`PayHub ${err.code} (${err.statusCode}): ${err.description}`);
}
const order = payload.data;
2. Authentication
Send your key as a bearer token on every request:
Authorization: Bearer phk_live_…
Create keys in the merchant panel (Settings → Integration). The key is shown once, at creation, and never again — PayHub stores only its SHA-256 and cannot recover it. Lost it? Create another and revoke the old one; you can hold several, so rotating costs no downtime.
Revoking takes effect on the very next request. So does suspending the merchant account.
There is no test mode
Keys are live only. There is no sandbox, and phk_test_ does not exist today.
In practice most of the API can be rehearsed for nothing: creating, reading back and cancelling an order move no money — nothing is charged until a customer opens the link and pays. Only a real payment cannot be rehearsed free, and the usual way to test one end to end is a small order that you refund straight away.
3. Idempotency
POST /orders requires an Idempotency-Key header. Any string you generate per logical order — a UUID, or your own invoice id — up to 255 characters.
Idempotency-Key: <a UUID, or your own invoice id — one per logical order>
- Same key, same body → you get the original response back. Retry a timeout as often as you like: it will not create a second order or a second link.
- Same key, different body →
409 idempotency.key_reused. PayHub will not answer a new question with an old answer. - Two identical requests at the same instant → one of them wins; the other answers
409 idempotency.in_flight. Send it again, unchanged, and you get the winner's response. - Keys expire after 24 hours. After that, the same key starts a new request.
4. Orders
Create an order
POST /orders
| Field | Required | Notes |
|---|---|---|
description | yes | What the payer is paying for. Shown at checkout. Max 500. |
currency | yes | ISO 4217, uppercase. Must be one your account accepts. |
amount | no | Whole units, greater than zero. Omit for an open amount — the payer names the figure at checkout. |
reference | no | Your own id (invoice number, booking ref). Returned on the order and in events. Max 120. |
expiresAt | no | ISO 8601. Must be in the future. Omit for a link that never expires. |
singleUse | no | Defaults to true. false lets one link be paid repeatedly, each payment its own order. |
customerEmail | no | If given, PayHub emails the link to them. |
customerPhone | no | Stored with the order. |
languages, defaultLanguage | no | Restrict checkout to specific languages. Both or neither. |
paymentMethodMode | no | all_enabled (default) offers every payment method your account has switched on. selected offers only the ones in allowedProcessorInstanceIds. |
allowedProcessorInstanceIds | with selected | The payment methods to offer for this order, by id — the same choice the panel's New order form makes with its checkboxes. Sending ids without a mode means selected. An id that is not one of your own payment methods is refused (order.unknown_payment_method). |
Headers — the same three on every call:
| Header | Value |
|---|---|
Authorization | Bearer and your key, e.g. Bearer phk_live_… |
Idempotency-Key | Any string unique to this order — a UUID, or your own invoice id. See §3. |
Content-Type | application/json |
Body — a JSON object with the fields above. Only description and currency are required:
{
"description": "Invoice 42",
"currency": "AMD",
"amount": 5000,
"reference": "INV-2026-0042",
"expiresAt": "2026-10-01T12:00:00Z",
"customerEmail": "payer@example.com"
}
Response — the order, and the link to send your payer to:
{ "hasError": false, "errors": [],
"data": {
"id": "ORD-1a2b3c4d",
"status": "created",
"amount": "5000.0000",
"currency": "AMD",
"reference": "INV-2026-0042",
"paymentUrl": "https://payhub.am/pay/pl-8f2c…",
"expiresAt": null,
"createdAt": "2026-09-17T09:00:00+00:00" } }
Send your payer to paymentUrl. Do not build that URL yourself — take it from the response, so a change of domain or path never breaks your integration.
amount is a string, deliberately: money read into a float by a JSON parser is money reported wrong.
Which payment methods you can offer
GET /payment-methods → the methods switched on for your account, each with the id that allowedProcessorInstanceIds takes, its type (arca, stripe, idram) and the name you gave it on Accepting payments.
{ "hasError": false, "errors": [],
"data": [ { "id": "proc-326c9e89", "type": "arca", "name": "IDBANK NEW" },
{ "id": "proc-f94bb410", "type": "stripe", "name": "Stripe" } ] }
Read it when you set up, or when you switch a method on or off — not on every order.
Cancel an unpaid order
POST /orders/{id}/cancel → success with no data.
Only an order still waiting for its first payment attempt — status created — can be cancelled. A paid order, one with an attempt in flight, and one already cancelled or expired all answer 409 order.not_cancellable. An order that is not yours answers 404.
Refund a paid order
POST /refunds — requires Idempotency-Key, and here it matters most: a retried create makes a spare order, a retried refund moves money twice. Your key also rides into the PSP's own refund key, so neither layer can double-pay.
| Field | Required | Notes |
|---|---|---|
orderId | yes | Must be a paid or partially-refunded order of yours. |
amount | no | Whole units. Omit to refund whatever is left. Cannot exceed the remaining balance. |
reason | no | Recorded against the refund. |
{ "hasError": false, "errors": [],
"data": { "refundId": "ref-9a8b7c6d", "orderId": "ORD-1a2b3c4d",
"status": "Refunded", "amount": "5000.0000",
"refundedTotal": "5000.0000", "refundedAt": "2026-09-17T11:02:00+00:00" } }
A refusal keeps its own reason: an order that cannot be refunded and an order that does not exist answer differently, so branch on code rather than treating every failure alike.
QR codes
There is no QR endpoint, and you do not need one: a QR for a payment link encodes exactly the paymentUrl string and nothing else. Generate it wherever you are printing it, with whatever library your language already has — it works offline and costs no round trip. The merchant panel also offers a QR to download for any link.
There is no "get order" endpoint
By design. You are told what happens to your orders — see §5 — rather than asking. If you have missed something, replay it (§5.3); that is one call for everything you missed, rather than one call per order, for ever.
5. Being told what happened
5.1 Set up your endpoint
In the merchant panel (Settings → Integration), give PayHub an https URL and a signing secret of at least 16 characters. The secret is write-only: PayHub will never show it back to you. Saving again without a secret keeps the one you have; enter a new one to replace it.
The address must be public. PayHub connects to it from inside its own network, so a loopback, private or link-local address is refused when you save it — and when it is dialled, if a public-looking name resolves to one. Redirects are not followed: a 3xx counts as a refusal like any other non-2xx answer, so point PayHub at the final address.
Then press Send a test event on the same screen. It posts the envelope below, signed exactly as §5.3 describes, to the endpoint you have just saved, and tells you what your server answered — the status code and the round trip, or the reason nothing answered at all. Its type is test, it names no order, and nothing was charged: a handler that switches on type should acknowledge it with a 2xx and do nothing else. Test sends are not recorded in your delivery log, which is a log of real payment notifications only.
5.2 What arrives
POST to your URL, Content-Type: application/json:
{ "id": "3f7c…", "type": "order.paid", "schemaVersion": 1,
"occurredAt": "2026-09-17T09:04:11+00:00",
"data": { "orderId": "ORD-1a2b3c4d", "amount": "5000.0000", "currency": "AMD" } }
| Header | Meaning |
|---|---|
X-Signature | sha256=<hex> — see below |
X-Signature-Timestamp | Unix seconds, and part of what is signed |
X-Event-Id | The event's id. Use it to discard duplicates. |
X-Event-Type | Same as type in the body. |
Events in v1 — four of them:
type | data | When |
|---|---|---|
order.paid | orderId, reference, amount, currency | The payment settled and PayHub stands behind it. |
order.refunded | orderId, reference, refundAmount | Money went back, in full or in part. |
order.failed | orderId, reference, reason, stillPayable | An attempt was declined. The order is still payable — stillPayable is always true; ask for another card rather than writing the order off. reason is the bank's own words when it gave any. |
order.expired | orderId, reference | The link ran out before anyone paid. |
reference is the id you sent when you created the order — your invoice number — or null if you sent none. Reconcile on it directly; there is no need to keep your own orderId map.
schemaVersion changes only when a field's MEANING changes. New fields can appear at any time — ignore the ones you do not know, and your integration keeps working.
Answer 2xx quickly. Anything else is a failure and will be retried. Do your own work after you have answered, not before.
5.3 Verify the signature
The signed string is {timestamp}.{raw body} — not the body alone, so a captured delivery cannot be replayed at you for ever.
import hashlib, hmac, time
def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # reject anything older than 5 minutes
return False
expected = "sha256=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature) # constant time, never ==
Verify against the raw bytes you received. Re-serialising the JSON first will change them and the signature will not match.
5.4 When delivery fails
PayHub retries 10s → 30s → 2m → 10m → 1h, then stops. If your endpoint was down for longer than that, the notification is not delivered — but the event is not lost.
Replay: GET /events?since=<cursor>&limit=<1-500>
{ "hasError": false, "errors": [],
"data": { "events": [ { "cursor": 1051, "id": "…", "type": "order.paid",
"schemaVersion": 1, "orderId": "ORD-…",
"occurredAt": "…", "data": { … } } ],
"nextCursor": 1051, "hasMore": false } }
Store nextCursor after you have processed a page, and send it back as since next time. since=0 is the beginning. The data of a replayed event is identical to the data of a delivered one, so one piece of your code handles both.
This is for catching up after an outage, not for polling: it is rate limited well below what a poll loop needs. If you find yourself calling it on a timer, use the webhooks instead.
Your delivery log: GET /webhook-deliveries?status=pending|delivered|dead&limit= returns attempt counts, the response code we got and the error — so you can debug your own endpoint without contacting us.
Re-drive one: POST /webhook-deliveries/{id}/retry, once you have fixed whatever was wrong.
6. Error codes
code | statusCode | Meaning |
|---|---|---|
idempotency.key_required | 400 | No Idempotency-Key header on a POST that needs one. |
idempotency.key_reused | 409 | That key was used with a different request body. |
idempotency.in_flight | 409 | The same request is being processed right now. Send it again, unchanged, to get its result. |
order.not_found | 404 | No such order — or it is not yours. |
order.not_cancellable | 409 | The order is no longer in a state that can be cancelled. |
order.not_refundable | 409 | Only a paid or partially refunded order can be refunded. |
order.invalid_refund_amount | 400 | The refund amount must be greater than zero. |
order.refund_unavailable | 409 | The order predates the payment service, so there is nothing here to refund against; refund it through the provider directly. |
order.claim_code_unavailable | 409 | A unique code for the order could not be generated. Retry the same request. |
order.unknown_payment_method | 400 | An id in allowedProcessorInstanceIds is not one of your payment methods. description names it. |
entity.concurrent_update | 409 | Another request changed the order at the same moment. Reload it and try again. |
entity.duplicate | 409 | Another request created the same record at the same moment. Reload and try again. |
billing.no_effective_subscription | 403 | The account has no active subscription. |
merchant.no_payment_method | 403 | No payment method is configured, so nothing could be paid. |
webhook.delivery_not_found | 404 | No such delivery. |
webhook.already_delivered | 409 | That delivery already succeeded. |
integration.key_name_taken | 409 | Another of your keys that is still live has that name. Revoked names are free to reuse. |
| (validation) | 400 | field names the input at fault. |
A refund the payment service itself refuses — more than what is left, or a payment it cannot refund yet — comes back as 409 with a code beginning order.; branch on the prefix for those.
Switch on code. Never match on description — the wording may change, and it is translated.
7. Limits
- 120 requests per minute per key by default. Keys are counted separately, so one key's traffic never eats another's.
GET /eventsreturns up to 500 events per call.GET /webhook-deliveriesreturns up to 200 rows per call.
8. Versioning
There is no version segment in the URL, and that is a stronger promise than one: a published route never changes shape. Fields may be ADDED to a response, and your code should ignore fields it does not recognise. Anything that would break you becomes a new route instead.