Getting started
How payments work
Four actors, one record, five statuses. Understanding this page makes every other page easier: it explains where your code sits and why the server — not the redirect — is the source of truth.
The cast of a payment
Customer
browser
Chooses to pay and is redirected to the provider’s checkout. The browser is untrusted.
Your server
@obhco/payments
Creates the payment with your service credential, stores the reference, and verifies status.
OBH API
/api/v1
Owns the payment record, the provider account, verification, and webhook delivery.
Provider
server-side only
Collects the money. Its credentials never leave OBH; its result is verified by OBH.
Your integration only ever talks to the OBH API. The provider is OBH’s concern: OBH chooses the provider account, stores its credentials, creates the checkout session, and verifies the result. You never pass a provider token, and the SDK has no provider code in it.
The flow, step by step
- Your server creates the payment. You call
startPayment()with an order id, amount, and description. OBH stores a payment inpendingand returns itsreferenceplus, usually, acheckoutUrl. - The customer pays. You redirect the browser to the checkout URL. The provider handles the actual payment UI.
- The customer returns. The provider sends the browser to your
returnUrl. This is a hint, not proof. - OBH verifies it. OBH talks to the provider from the server and decides whether the payment succeeded.
- You read the result. Call
getPayment()with the stored reference, or wait for a signed webhook and then retrieve. Terminal statuses never change again.
Why verify after a webhook too?
Webhooks can arrive out of order, more than once, or after a delayed retry. They tell you that something happened; getPayment() tells you what is true now. Use the event as a trigger to retrieve, not as the state itself.
The payment object
Every method returns the same shape. Additional fields may be added in the future without breaking your integration, so ignore unknown ones.
| Field | Type | Meaning |
|---|---|---|
id | string | Internal payment identifier. |
reference | string | OBH reference used to retrieve the payment. |
service | string | The authenticated service that owns the payment. |
externalOrderId | string | null | Your order id, echoed back. |
amount | string | Decimal amount, always a string. |
currency | string | Currently always IRR. |
environment | test | live | Which financial environment owns the record. |
status | PaymentStatus | The verified lifecycle state. |
description | string | The description you supplied. |
checkoutUrl | string | null | Provider checkout URL, when one exists. |
createdAt | string | RFC 3339 UTC timestamp. |
completedAt | string | null | When the payment completed, if it did. |
verifiedAt | string | null | When server-side verification last ran. |
Timestamps are RFC 3339 UTC strings. Store them as timestamps, not as localized display strings.
Payment statuses
There are exactly five statuses. Three of them are terminal, and pending is the only non-terminal one.
Created
A provider session exists or is being created. The result is not verified yet.
Terminal outcomes
- completedProvider verification succeeded. Fulfil the order.
- failedVerification or provider processing failed.
- cancelledCancelled by an authorized process.
- expiredThe checkout session expired without success.
A terminal payment never transitions again. A pending payment can move to any terminal outcome.
| Status | Meaning | Terminal |
|---|---|---|
| pending | A provider session exists or is being created; the result is unverified. | No |
| completed | Server-side provider verification succeeded. | Yes |
| failed | Verification or provider processing failed. | Yes |
| cancelled | Cancelled by an authorized process. | Yes |
| expired | Checkout or session expired without success. | Yes |
Do not build business logic on the exact wording of a status. Build it on three questions: is it completed? is it pending? or is it a terminal non-success?
if (payment.status === "completed") { await fulfilOrder(payment.externalOrderId)} else if (payment.status === "pending") { await showPendingScreen(payment.reference)} else { await showFailureScreen(payment.status)}Money
Amounts are decimal strings, never JavaScript numbers. One rial is one unit; there are no minor units to divide by 100.
- Currency is fixed to
IRR. No conversion is performed. - The amount must be positive, with up to two decimal places and at most 18 integer digits.
"245000000"and"1000.50"are valid.245000000and"0"are not.- Responses always normalize the amount to two decimal places: sending
"245000000"returns"245000000.00". Compare amounts after normalization, not as the raw string you sent.
Keep the amount as a string all the way from your order to the API. Parsing it into a float to “clean it up” is how rounding bugs and mismatched orders appear.
Environments
Every payment belongs to an environment, and every credential is bound to one:
TEST key: svc_test_<key-id>.<secret>LIVE key: svc_live_<key-id>.<secret>Today only TEST credentials are issued. A TEST payment record can never become a LIVE financial record, and a TEST key cannot read or write LIVE data. The environment field on a payment tells you which world you are in.
Test money is real-looking, but not real
The development adapter simulates a checkout locally and marks its results as simulated. It never contacts a provider. Do not reconcile test payments as revenue.
The source of truth
There are three ways a payment result can reach your application, and they are not equally trustworthy:
- The return URL — a browser navigation. Useful for routing; worthless as evidence.
- A webhook — a signed server-to-server notification. Trustworthy as a signal, but it can be delayed or duplicated.
getPayment()— a fresh read from OBH. This is the authoritative state.
Call getPayment() before granting access, shipping a product, or recording revenue. If a webhook told you the payment completed, retrieve it once more and check.
Next, put this into practice: Create a payment.