payriff-sdk
v0.1.1
Published
TypeScript client for the Payriff payment gateway
Downloads
293
Maintainers
Readme
payriff-sdk
TypeScript client for the Payriff payment gateway. No runtime dependencies, ships ESM and CJS, types included.
npm install payriff-sdkQuick start
import { PayriffClient } from 'payriff-sdk'
const client = new PayriffClient({ secretKey: process.env.PAYRIFF_SECRET_KEY! })
const order = await client.createOrder({
amount: 25.5,
description: 'Order 1024',
callbackUrl: 'https://shop.example/payriff/callback',
})
console.log(order.paymentUrl)Send the customer to order.paymentUrl. Payriff posts to your callbackUrl as the status
changes.
A whole checkout
The shape of a real integration, here with Express. Two routes: one to start the payment, one to receive the result.
import express from 'express'
import { GatewayError, parseCallback, PaymentStatus, PayriffClient } from 'payriff-sdk'
const app = express()
app.use(express.json())
const client = PayriffClient.fromEnv()
app.post('/checkout/:cartId', async (req, res) => {
const cart = await loadCart(req.params.cartId)
try {
const order = await client.createOrder({
amount: cart.total,
description: `Order ${cart.id}`,
callbackUrl: 'https://shop.example/payriff/callback',
})
cart.payriffOrderId = order.orderId
await cart.save()
res.redirect(order.paymentUrl!)
} catch (error) {
if (error instanceof GatewayError) {
console.warn(`payriff refused the order: ${error.code} ${error.message}`)
return res.status(502).json({ error: 'could not start the payment' })
}
throw error
}
})
app.post('/payriff/callback', async (req, res) => {
const event = parseCallback(req.body)
// The callback is unsigned, so nothing here is trusted until the API agrees.
const order = await client.getOrder(event.orderId)
const cart = await loadCartByOrder(event.orderId)
if (order.settled) {
await cart.markPaid()
} else if (
([PaymentStatus.DECLINED, PaymentStatus.CANCELED] as string[]).includes(
order.paymentStatus ?? '',
)
) {
await cart.markFailed(order.paymentStatus!)
}
res.sendStatus(200)
})Two details worth copying. The callback handler decides on order, the value it fetched, never
on event, the value it was sent. And it answers 200 whatever the outcome, because a non-2xx
tells Payriff to deliver the same event again.
Callbacks can arrive more than once for one order, so make markPaid idempotent.
Two things that catch people out
A success code doesn't mean the payment worked
The code field tells you whether Payriff accepted the call. Whether money actually moved is a
separate field, paymentStatus. autoPay is where this hurts most: a declined card still comes
back as 00000.
const charge = await client.autoPay({ cardUuid, amount: 10, description: 'Subscription' })
charge.ok // true
charge.paymentStatus // 'CANCELED'
charge.settled // falseGate your fulfilment on settled.
A hold is different again. A genuine pre-authorisation reports PREAUTH_APPROVED, and that is
the only status where money is reserved rather than taken. settled stays false for it and
authorized turns true.
const hold = await client.getOrder(orderId)
hold.authorized // true, the funds are reserved
hold.settled // false, nothing has moved yetWatch out for accounts without pre-authorisation enabled. There, an order sent with
operation=PRE_AUTH is charged like an ordinary sale and comes back APPROVED, which settled
correctly reports as paid. Check authorized, not the operation you asked for.
Callbacks aren't signed
There is no HMAC and no shared secret in the body, so there's nothing to verify. Anyone who learns your callback URL can post to it. Treat the callback as a nudge to go ask the API what happened:
import { parseCallback } from 'payriff-sdk'
const callback = parseCallback(req.body)
const order = await client.getOrder(callback.orderId)
if (order.settled) {
await fulfil(callback.orderId)
}parseCallback reads the body and nothing more. The getOrder call is what makes it safe.
Configuration
import { Currency, Language, PayriffClient } from 'payriff-sdk'
const client = new PayriffClient({
secretKey: 'your-secret-key',
merchantId: 'ES100000',
currency: Currency.AZN,
language: Language.EN,
callbackUrl: 'https://shop.example/payriff/callback',
timeout: 30_000,
})PayriffClient.fromEnv() reads PAYRIFF_SECRET_KEY, PAYRIFF_MERCHANT_ID, PAYRIFF_BASE_URL
and PAYRIFF_CALLBACK_URL instead.
The secret goes into the Authorization header raw, with no Bearer prefix. The client handles
that for you.
Pass your own fetch if you need requests to go through a proxy or an instrumented client.
Methods
| Method | Endpoint |
|---|---|
| createOrder | POST /api/v3/orders |
| reserve | POST /api/v3/orders with operation=PRE_AUTH |
| getOrder | GET /api/v3/orders/{id} |
| complete | POST /api/v3/complete |
| refund | POST /api/v3/refund |
| autoPay | POST /api/v3/autoPay |
| saveCard, refundCardSave | the two step card save flow |
| transfer | POST /api/v3/payout |
| createInvoice | POST /api/v2/invoices |
| getInvoice | POST /api/v2/get-invoice |
A couple of things worth knowing. The docs describe order information as a POST, but the API
answers that with a 405 and only takes GET, which is what this does. And /api/v3/payout moves
money between Payriff merchant wallets, so transfer seemed the honest name for it; if you were
hoping for a bank payout, this isn't it.
The v2 invoice endpoints want merchant set and refuse the call without it. The v3 order
endpoints do not care, so set merchantId on the client if you touch invoices.
Bulk invoice and bulk payout are dashboard features driven by spreadsheet upload. There is no API behind them, so there's nothing here for them either.
For anything else, go straight at it:
await client.request('POST', '/api/v3/directPay', { ... })Pre-authorisation
reserve puts a hold on the card instead of charging it. You have 30 days to capture with
complete, after which the hold expires on its own. Capture less than you held and Payriff
releases the difference.
const hold = await client.reserve({ amount: 100, description: 'Hotel booking', threeDS: true })
await client.complete(hold.orderId!, 80)Saving a card
To verify a card, Payriff charges 0.01 AZN and expects you to hand it straight back. Skipping the refund leaves the customer out of pocket, so treat it as step two rather than tidying up:
const verification = await client.saveCard({
callbackUrl: 'https://shop.example/payriff/callback',
})
// the customer completes verification.paymentUrl, then
await client.refundCardSave(verification.orderId!)The cardUuid you need for autoPay arrives in the callback.
Responses
Branch on code rather than the HTTP status. The interesting fields live under payload, and
PayriffResponse reads through to them:
order.code // '00000'
order.codeName // 'SUCCESS'
order.ok // the call was accepted
order.settled // money actually moved
order.authorized // funds held by a pre-authorisation, not yet captured
order.operationType // 'PURCHASE', 'PRE_AUTH', ...
order.orderId, order.sessionId, order.transactionId, order.paymentUrl
order.paymentStatus // 'PAID', 'CANCELED', ...
order.transactions // per attempt: card mask, RRN, channel
order.payload // raw payload, occasionally a plain string such as 'APPROVED'
order.raw // untouched bodyEnums
Each one is a const object with a matching type, so you get both the values and the union. Every member is a plain string, so a raw string still works anywhere an enum is accepted; the enum is what stops a typo reaching the gateway.
import { Currency, Language, PaymentStatus } from 'payriff-sdk'
await client.createOrder({
amount: 10,
description: 'x',
currency: Currency.USD,
language: Language.AZ,
})
if (order.paymentStatus === PaymentStatus.APPROVED) {
// ...
}| Enum | Values |
|---|---|
| Operation | PURCHASE, PRE_AUTH, COMPLETE, REFUND, REVERSE |
| Language | AZ, EN, RU, AR |
| Currency | AZN, USD, EUR, PKR, AED, SAR |
| PaymentType | ONETIME, DAILY, WEEKLY, MONTHLY, ANNUALLY |
| InstallmentProduct | BIRKART, ALBALI, BOLKART, TAMKART |
| InvoiceStatus | PENDING, ERROR, EXPIRED, PARTIAL, COMPLETE, CASH |
| PaymentStatus | CREATED, APPROVED, CANCELED, DECLINED, REFUNDED, PREAUTH_APPROVED, EXPIRED, REVERSE, PARTIAL_REFUND, PARTIAL, ACCEPTED, REFUND_IN_PROGRESS, CASH, PENDING, PREAUTH_EXPIRED |
| ResultCode | SUCCESS, WARNING, ERROR, INVALID_PARAMETERS, UNAUTHORIZED, TOKEN_NOT_PRESENT, INVALID_TOKEN, INVALID_ORIGIN, CHECKING |
| GatewayResult | 00, APPROVED, PREAUTH-APPROVED |
CURRENCY_NUMERIC and PAYMENT_STATUS_CODES give you the ISO 4217 and internal numeric codes.
PaymentStatus carries two extras, PAID and COMPLETED. Payriff's enum reference leaves them
out, but the order information and autoPay examples both return them, so they are in here.
SETTLED_PAYMENT_STATUSES and HELD_PAYMENT_STATUSES are the sets behind settled and
authorized.
Errors
GatewayError means Payriff refused the call: any result code other than 00000 or 01000, or
a 4xx that still carried an envelope. It gives you code, message, internalMessage,
responseId and the raw body.
TransportError covers the rest, so network failures, timeouts, and responses that either
weren't JSON or carried no envelope at all.
Both extend PayriffError if you want to catch broadly.
import { GatewayError, TransportError } from 'payriff-sdk'
try {
const order = await client.createOrder({ amount: 25.5, description: 'Order 1024' })
} catch (error) {
if (error instanceof GatewayError) {
// Payriff answered and said no. error.code tells you why.
console.warn(`refused: ${error.code} ${error.message}`)
} else if (error instanceof TransportError) {
// Nothing came back. Safe to retry.
console.error(`payriff unreachable: ${error.message}`)
} else {
throw error
}
}Retry TransportError if you like, but not GatewayError: the gateway already made up its mind
and the same call will be refused again.
The 01000 code is a bit odd. On a 2xx it's a warning and comes back as a normal response. On
a 4xx it's a refusal and throws, which is how Payriff answers an unknown application key.
Testing
Payriff runs a sandbox. Point PAYRIFF_BASE_URL at it, use a sandbox key, and pay with the
published test cards:
| Brand | Number | Expiry | CVV | OTP | |---|---|---|---|---| | Visa | 4000007546012078 | 04/29 | 893 | 123456 | | Mastercard | 5100007346013947 | 04/29 | 783 | 123456 |
test/sandbox.test.ts runs when PAYRIFF_SECRET_KEY is set and skips when it isn't, so a
checkout without a key still passes.
What has been checked against the live gateway
Payriff's docs turned out to disagree with the API in a few places, so the flows below were run against a real account and the parsing is built from the recorded bodies.
| Verified | Inferred from the docs only | |---|---| | create order, and the CREATED to PENDING to APPROVED or DECLINED lifecycle | complete, a capture | | that a pre-auth order settles outright on an account without pre-auth | auto pay | | order lookup, which is a GET despite the docs saying POST | card save and its refund | | refund, which answers with a null payload, on both a purchase and a pre-auth | invoice create and lookup | | the callback envelope, for a declined purchase | wallet transfer | | result codes 15400, 15000 and 01000, and the 4xx behaviour | |
Pre-authorisation, autopay and invoicing are not switched on for the account used, so those paths could not be exercised. Treat the right hand column as untested and please report anything that looks wrong.
Licence
MIT. Not affiliated with Payriff.
