orvacon

paykit

The core orchestrator — operations, money, state, idempotency, the ledger, and webhooks.

@orvacon/paykit is the core. It owns everything that isn't gateway-specific: the payment state machine, Money typing, idempotency, the hash-chained ledger, and Ed25519 webhook signing. Gateways plug in behind it as connectors; your code calls one type-safe API and never learns which gateway ran the charge.

The instance

orvacon() validates its config fail-fast — a missing or malformed key throws at setup, not at the first payment — and returns the orchestrator.

lib/orva.ts
import { orvacon } from "@orvacon/paykit";
import { iyzico } from "@orvacon/connector-iyzico";

export const orva = orvacon({
  database,                         // a DatabaseAdapter (bring your own)
  connectors: [iyzico({ apiKey, secretKey })],
  webhookSigningKey,                // Ed25519 secret, orvsk_… — signs outbound webhooks
  webhookUrl,                       // where orvacon POSTs signed lifecycle events (optional)
});
Config
databaseRequired. Your DatabaseAdapter — the core persists every payment, event, and ledger row through it.
connectorsRequired. The registered gateways. With one, connectorId on a request is optional.
webhookSigningKeyRequired. The Ed25519 secret that signs outbound webhooks. There is no unsigned mode.
webhookUrlWhere signed lifecycle webhooks are POSTed. Omit it and only in-process hooks fire.
hooksEvent-keyed in-process callbacks, e.g. { "payment.captured": (p) => … }.
timeoutPer-gateway-call timeout (ms). Default 30_000.

Operations

Every mutating call is idempotency-keyed and returns the same discriminated shape — check ok first:

const outcome = await orva.authorize({ /* … */, idempotencyKey });

if (!outcome.ok) {
  outcome.error; // { code, message } — see the error model below
  return;
}

outcome.paymentId; // orvacon's payment id (a pay_… ULID)
outcome.status;    // "authorized" | "captured" | "requires_action" | "refunded"

if (outcome.status === "requires_action") {
  outcome.action; // the challenge to hand the browser — { type: "html", content } for Iyzico 3DS
}

0.3.0 changed this shape

Operation outcomes now carry ok at the top — outcome.ok, not outcome.result.ok. Every method (including reconcile / storeCard / deleteCard) is checked the same way.

MethodWhat it does
authorize(req)Begin a payment. Returns requires_action with a challenge for 3-D Secure.
capture(req)Settle authorized funds. Rejected on a connector that captures at authorize (Iyzico).
refund(req)Reverse a captured payment, full or partial.
reconcile(paymentId)Re-read a payment stuck at requires_action from the gateway and settle it if the gateway already did.
storeCard(req)Vault a card and return a CardToken to charge later. orvacon stores nothing.
deleteCard(req)Forget a vaulted card at the gateway.
handleWebhook(connectorId, raw)Verify (with the gateway's own scheme) and apply an inbound gateway webhook.
drainWebhooks()Await every in-flight outbound delivery — call it before a short-lived process exits.

The error model

A failed operation carries a normalized error.code — the core decides on the class, never the gateway's raw code. Only gateway_error is auto-retried; an ambiguous outcome is never blindly retried, because a wrongly retried charge is worse than a surfaced error.

codeMeaning
declinedThe gateway said no. Final.
gateway_errorTransient gateway/network failure — the only auto-retry candidate.
invalid_requestThe request was malformed (a bug).
auth_errorConnector credentials are invalid (configuration).
conflictAnother request holding the same idempotency key is still in flight.
unknownUnclassifiable, outcome ambiguous — surfaced for reconciliation, never auto-retried.

Money

Money is integer minor units plus a currency — never a float. The branded Money type stops a bare number from being passed where money is expected, and arithmetic stays correct only through the helpers.

import { money, addMoney, subtractMoney, compareMoney } from "@orvacon/paykit";

const price = money(1499, "TRY"); // 14.99 TRY — 1499 *minor units* (kuruş)

money() fails fast on a non-integer, negative, or malformed input. A refund is a positive amount; its direction is carried by the ledger, not by the sign of the number. Floats may appear only at a connector's gateway boundary — never in the core.

Payment state

A payment moves through a state machine enforced at compile time and re-validated at runtime (connector and webhook values arrive as plain strings):

created → requires_action → authorized → captured → (partially_refunded →) refunded
              ↘ failed                ↘ voided

partially_refunded is reached when a refund moves less than the captured amount; further refunds stay there until the cumulative total equals the capture, at which point it is refunded. voided arrives only as a gateway-initiated cancellation webhook — there is no core void() in v1.

Idempotency

Every money-moving call takes a client-generated idempotencyKey — one key per logical intent, reused unchanged on every retry of that intent.

import { idempotencyKey } from "@orvacon/paykit";

const key = idempotencyKey(crypto.randomUUID()); // brand a unique string
await orva.authorize({ /* … */, idempotencyKey: key }); // safe to retry verbatim

Uniqueness is enforced atomically by a database unique constraint (not a cache). A replayed key returns the original outcome, never a fresh charge.

The ledger

Every state transition writes a ledger entry in the same database transaction — the state and the ledger can never disagree. Entries are append-only and hash-chained: each row commits to the previous one, so tampering with history is evident without trusting anyone. There is no separate "mark as paid" step to forget.

Webhooks

Two signature worlds, never conflated:

  • Outbound (orvacon → your endpoint): signed with Ed25519 (asymmetric), so a leaked verification key can't forge events. Delivery is at-least-once and fire-and-forget; the payment flow never blocks on your endpoint.
  • Inbound (gateway → orvacon): verified in the connector's parseWebhook with the gateway's own scheme (Iyzico hex, PayTR base64).

Every signature and token comparison runs in constant time — never ===. For 3-D Secure, orvacon trusts the signed finalize response, never the raw callback POST.

Plugins

A plugin extends the orchestration without touching the connector or your application code — bound with plugins: []. It hooks in at two points:

  • beforeAuthorize runs before the gateway, in registration order (each plugin sees the previous one's transform), and may rewrite the request — add tax to the amount — or veto it — a fraud block. A beforeAuthorize that throws fails the charge closed, so a tax or fraud step that errored is never silently skipped.
  • hooks react to persisted lifecycle events, merged with the instance's own hooks and each isolated — a throwing handler is reported through onError, never breaking the payment flow.
import { addMoney, money, type OrvaconPlugin } from "@orvacon/paykit";

const taxkit = (rate: number): OrvaconPlugin => ({
  name: "taxkit",
  beforeAuthorize: (_ctx, request) => ({
    ...request,
    amount: addMoney(
      request.amount,
      money(Math.round(request.amount.amount * rate), request.amount.currency),
    ),
  }),
});

orvacon({ /* … */, plugins: [taxkit(0.2)] });

The core never lets a plugin change the idempotencyKey or connectorId — a plugin reshapes what is charged, never where or the replay identity. Higher-level kits (subscriptions, tax, fraud) build on this same contract.

On this page