OBHPayments
↑↓ navigate↵ openSearches titles and topics
Documentation

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

  1. Your server creates the payment. You call startPayment() with an order id, amount, and description. OBH stores a payment in pending and returns its reference plus, usually, a checkoutUrl.
  2. The customer pays. You redirect the browser to the checkout URL. The provider handles the actual payment UI.
  3. The customer returns. The provider sends the browser to your returnUrl. This is a hint, not proof.
  4. OBH verifies it. OBH talks to the provider from the server and decides whether the payment succeeded.
  5. 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.

FieldTypeMeaning
idstringInternal payment identifier.
referencestringOBH reference used to retrieve the payment.
servicestringThe authenticated service that owns the payment.
externalOrderIdstring | nullYour order id, echoed back.
amountstringDecimal amount, always a string.
currencystringCurrently always IRR.
environmenttest | liveWhich financial environment owns the record.
statusPaymentStatusThe verified lifecycle state.
descriptionstringThe description you supplied.
checkoutUrlstring | nullProvider checkout URL, when one exists.
createdAtstringRFC 3339 UTC timestamp.
completedAtstring | nullWhen the payment completed, if it did.
verifiedAtstring | nullWhen 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

pending

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.

StatusMeaningTerminal
pendingA provider session exists or is being created; the result is unverified.No
completedServer-side provider verification succeeded.Yes
failedVerification or provider processing failed.Yes
cancelledCancelled by an authorized process.Yes
expiredCheckout 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?

Fulfilment rulets
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. 245000000 and "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:

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

  1. The return URL — a browser navigation. Useful for routing; worthless as evidence.
  2. A webhook — a signed server-to-server notification. Trustworthy as a signal, but it can be delayed or duplicated.
  3. 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.