Node.js SDK

Use the official @teyra/pay library to accept payments, paginate lists, retry safely and verify webhooks from Node.js and TypeScript.

Coming soon

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-Key on every POST, reused across retries
  • Automatic retries on network errors, 429 and 5xx
  • Typed errors with code, status and details
  • Money helpers that never use floats
  • Webhook signature verification

Install

Requires Node.js 18 or later.

npm install @teyra/pay

Quick 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

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

ClassStatus
TeyraPayInvalidRequestError400, 422: validation_error, refund_exceeds_amount, payment_not_refundable
TeyraPayAuthenticationError401
TeyraPayPermissionError403: merchant_not_active, forbidden
TeyraPayNotFoundError404
TeyraPayConflictError, TeyraPayIdempotencyError409
TeyraPayRateLimitError429 (retryAfter in seconds)
TeyraPayAPIError5xx
TeyraPayConnectionErrorNetwork 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 places

See 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.