PayHubIntegration settings

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

FieldRequiredNotes
descriptionyesWhat the payer is paying for. Shown at checkout. Max 500.
currencyyesISO 4217, uppercase. Must be one your account accepts.
amountnoWhole units, greater than zero. Omit for an open amount — the payer names the figure at checkout.
referencenoYour own id (invoice number, booking ref). Returned on the order and in events. Max 120.
expiresAtnoISO 8601. Must be in the future. Omit for a link that never expires.
singleUsenoDefaults to true. false lets one link be paid repeatedly, each payment its own order.
customerEmailnoIf given, PayHub emails the link to them.
customerPhonenoStored with the order.
languages, defaultLanguagenoRestrict checkout to specific languages. Both or neither.
paymentMethodModenoall_enabled (default) offers every payment method your account has switched on. selected offers only the ones in allowedProcessorInstanceIds.
allowedProcessorInstanceIdswith selectedThe 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:

HeaderValue
AuthorizationBearer and your key, e.g. Bearer phk_live_…
Idempotency-KeyAny string unique to this order — a UUID, or your own invoice id. See §3.
Content-Typeapplication/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.

FieldRequiredNotes
orderIdyesMust be a paid or partially-refunded order of yours.
amountnoWhole units. Omit to refund whatever is left. Cannot exceed the remaining balance.
reasonnoRecorded 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" } }
HeaderMeaning
X-Signaturesha256=<hex> — see below
X-Signature-TimestampUnix seconds, and part of what is signed
X-Event-IdThe event's id. Use it to discard duplicates.
X-Event-TypeSame as type in the body.

Events in v1 — four of them:

typedataWhen
order.paidorderId, reference, amount, currencyThe payment settled and PayHub stands behind it.
order.refundedorderId, reference, refundAmountMoney went back, in full or in part.
order.failedorderId, reference, reason, stillPayableAn 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.expiredorderId, referenceThe 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

codestatusCodeMeaning
idempotency.key_required400No Idempotency-Key header on a POST that needs one.
idempotency.key_reused409That key was used with a different request body.
idempotency.in_flight409The same request is being processed right now. Send it again, unchanged, to get its result.
order.not_found404No such order — or it is not yours.
order.not_cancellable409The order is no longer in a state that can be cancelled.
order.not_refundable409Only a paid or partially refunded order can be refunded.
order.invalid_refund_amount400The refund amount must be greater than zero.
order.refund_unavailable409The order predates the payment service, so there is nothing here to refund against; refund it through the provider directly.
order.claim_code_unavailable409A unique code for the order could not be generated. Retry the same request.
order.unknown_payment_method400An id in allowedProcessorInstanceIds is not one of your payment methods. description names it.
entity.concurrent_update409Another request changed the order at the same moment. Reload it and try again.
entity.duplicate409Another request created the same record at the same moment. Reload and try again.
billing.no_effective_subscription403The account has no active subscription.
merchant.no_payment_method403No payment method is configured, so nothing could be paid.
webhook.delivery_not_found404No such delivery.
webhook.already_delivered409That delivery already succeeded.
integration.key_name_taken409Another of your keys that is still live has that name. Revoked names are free to reuse.
(validation)400field 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 /events returns up to 500 events per call.
  • GET /webhook-deliveries returns 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.