Node.js SDK
Use the official @teyra/pay library to accept payments, paginate lists, retry safely and verify webhooks from Node.js and TypeScript.
Not published yet
@teyra/pay is built and tested but not on npm yet, so npm install @teyra/pay will not work until it is released. Contact support@teyra.co for early access. Until then, call the REST API directly as shown in the quick start.
@teyra/pay is the official Teyra Pay client for Node.js. It is written in TypeScript, ships ESM and CommonJS builds, and has no runtime dependencies.
- Typed methods for payments, refunds, payment links, deposits and webhook endpoints
- Async auto-pagination with
for await - An
Idempotency-Keyon everyPOST, reused across retries - Automatic retries on network errors,
429and5xx - Typed errors with
code,statusanddetails - Money helpers that never use floats
- Webhook signature verification
Install
Requires Node.js 18 or later.
npm install @teyra/payQuick start
Create a client with your secret key, create a payment and send the customer to checkout_url.
import { TeyraPay, toMinor } from '@teyra/pay';
const teyra = new TeyraPay(process.env.TEYRA_SECRET_KEY!); // sk_test_… or sk_live_…
const payment = await teyra.payments.create({
amount: toMinor('12.500', 'KWD'), // 12500
currency: 'KWD',
return_url: 'https://shop.example.com/thanks',
reference: 'order-1042',
customer: { name: 'Sara Ali', email: 'sara@example.com' },
metadata: { order_id: '1042' },
});
// Redirect the customer to the hosted checkout page
res.redirect(303, payment.checkout_url);When the customer comes back to your return_url, confirm the result on your server. Don't trust the redirect alone:
const payment = await teyra.payments.retrieve(req.query.payment_id as string);
if (payment.status === 'succeeded') {
// mark the order as paid
}CommonJS works too: const { TeyraPay } = require('@teyra/pay');
Keep the secret key on your server
Never ship sk_test_… or sk_live_… keys to a browser or mobile app. Load them from an environment variable. See Authentication.
Client options
const teyra = new TeyraPay(process.env.TEYRA_SECRET_KEY!, {
baseUrl: 'https://api.teyra.co', // default
timeoutMs: 30_000, // per attempt, default 30 seconds
maxRetries: 2, // default 2
});The key decides the mode: test keys create test objects (livemode: false) and live keys create live ones. See Test and live mode.
Methods
| Method | Endpoint |
|---|---|
payments.create(params) | POST /v1/payments |
payments.retrieve(id) | GET /v1/payments/:id |
payments.list(params) | GET /v1/payments |
refunds.create(paymentId, params) | POST /v1/payments/:id/refunds |
refunds.retrieve(id) | GET /v1/refunds/:id |
refunds.list(params) | GET /v1/refunds |
paymentLinks.create(params) | POST /v1/payment-links |
paymentLinks.retrieve(id) | GET /v1/payment-links/:id |
paymentLinks.list(params) | GET /v1/payment-links |
paymentLinks.deactivate(id) | POST /v1/payment-links/:id/deactivate |
deposits.list(params) | GET /v1/deposits |
webhookEndpoints.create(params) | POST /v1/webhook-endpoints |
webhookEndpoints.list() | GET /v1/webhook-endpoints |
webhookEndpoints.delete(id) | DELETE /v1/webhook-endpoints/:id |
Parameters and responses match the API reference. Every method also accepts request options as its last argument: { idempotencyKey, timeoutMs, maxRetries, signal }.
// Partial refund
const refund = await teyra.refunds.create('pay_01JB…', { amount: 5000, reason: 'requested_by_customer' });
// Shareable payment link
const link = await teyra.paymentLinks.create({ amount: 5000, currency: 'KWD', description: 'Consultation' });
console.log(link.url);Pagination
list() returns a page list. await it to get one page, or iterate it with for await to walk every page. The SDK requests the next page with next_cursor only when you reach it, and keeps your filters on every request.
// One page
const page = await teyra.payments.list({ status: 'succeeded', limit: 50 });
console.log(page.data.length, page.has_more, page.next_cursor);
// Every item, across pages
for await (const payment of teyra.payments.list({ status: 'succeeded', created_gte: '2026-10-01T00:00:00Z' })) {
console.log(payment.id, payment.amount);
}
// Collect up to 200 items
const refunds = await teyra.refunds.list({ payment_id: 'pay_01JB…' }).toArray(200);
// Page by page
for await (const p of teyra.deposits.list({ limit: 100 }).pages()) {
console.log(p.data.length);
}See Pagination for how cursors work.
Idempotency
The SDK sends an Idempotency-Key header with every POST. If you don't pass one, it generates a UUID and reuses it on every retry of that call, so a retried request can't create a second payment or refund.
When the same operation might run again later, for example from a queued job or a double-clicked button, pass your own key derived from your order:
await teyra.payments.create(params, { idempotencyKey: 'order-1042-attempt-1' });
await teyra.refunds.create('pay_01JB…', { amount: 5000 }, { idempotencyKey: 'refund-order-1042-item-3' });Reusing a key with a different body throws TeyraPayIdempotencyError (409 idempotency_conflict). See Idempotency.
Errors and retries
Every API error is a TeyraPayError with code, status, message and details. Subclasses let you branch on the type:
| Class | Status |
|---|---|
TeyraPayInvalidRequestError | 400, 422: validation_error, refund_exceeds_amount, payment_not_refundable |
TeyraPayAuthenticationError | 401 |
TeyraPayPermissionError | 403: merchant_not_active, forbidden |
TeyraPayNotFoundError | 404 |
TeyraPayConflictError, TeyraPayIdempotencyError | 409 |
TeyraPayRateLimitError | 429 (retryAfter in seconds) |
TeyraPayAPIError | 5xx |
TeyraPayConnectionError | Network error or timeout (no status) |
import { TeyraPayError, TeyraPayInvalidRequestError } from '@teyra/pay';
try {
await teyra.refunds.create('pay_01JB…', { amount: 999999 });
} catch (err) {
if (err instanceof TeyraPayInvalidRequestError) {
console.log(err.code); // e.g. refund_exceeds_amount
console.log(err.details); // e.g. { fields: { amount: '…' } } on validation_error
} else if (err instanceof TeyraPayError) {
console.error(err.status, err.code, err.message);
} else {
throw err;
}
}Network errors, timeouts, 429 and 5xx responses are retried up to maxRetries times (default 2) with exponential backoff of 0.5 s, 1 s, 2 s and so on, up to 8 s. When the API sends Retry-After, the SDK waits that long instead. Other 4xx errors are never retried. See Errors for every code.
Money helpers
Amounts are integers in minor units. Convert with the helpers instead of multiplying floats:
import { toMinor, fromMinor, currencyExponent } from '@teyra/pay';
toMinor('1.500', 'KWD'); // 1500
toMinor('19.99', 'AED'); // 1999
fromMinor(1500, 'KWD'); // '1.500'
fromMinor(2500, 'SAR'); // '25.00'
currencyExponent('BHD'); // 3
toMinor('1.2345', 'KWD'); // throws: KWD amounts have at most 3 decimal placesSee Money and currencies.
Verify webhooks
webhooks.constructEvent(rawBody, signatureHeader, secret, toleranceSec = 300) checks the Teyra-Signature header and returns the parsed event. It throws TeyraPaySignatureVerificationError when the header is missing, the timestamp is more than toleranceSec seconds away, or no signature matches. Pass the raw body, not parsed JSON.
import express from 'express';
import { TeyraPay, TeyraPaySignatureVerificationError } from '@teyra/pay';
const teyra = new TeyraPay(process.env.TEYRA_SECRET_KEY!);
const app = express();
// express.raw keeps the body as a Buffer. Don't use express.json() on this route.
app.post('/hooks/teyra', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = teyra.webhooks.constructEvent(req.body, req.get('Teyra-Signature'), process.env.TEYRA_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof TeyraPaySignatureVerificationError) return res.status(400).send('Invalid signature');
throw err;
}
if (event.type === 'payment.succeeded') {
await markOrderPaid(event.data.object.id);
}
res.sendStatus(200);
});generateTestHeader(payload, secret) builds a valid header for your own tests. See Verify webhook signatures and Webhooks.
Try the API in Postman
Prefer to click through requests first? Import the Postman collection.