@tuppence/node 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,132 @@
1
- # Temporary Holding Version
1
+ # @tuppence/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Tuppence API for Node.js (18+). Payments for developers and AI agents.
4
+
5
+ ```sh
6
+ npm install @tuppence/node
7
+ ```
8
+
9
+ ```ts
10
+ import { Tuppence } from "@tuppence/node";
11
+
12
+ const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY!);
13
+
14
+ // A payment link: share its URL, get paid.
15
+ const link = await tuppence.paymentLinks.create({ name: "Pro plan", amount: 2000 });
16
+ console.log(link.url);
17
+
18
+ // Every page of a list, fetched as you go.
19
+ for await (const payment of tuppence.payments.listAll({ limit: 100 })) {
20
+ console.log(payment.id, payment.status, payment.fee);
21
+ }
22
+ ```
23
+
24
+ ## Retries are safe
25
+
26
+ Every POST carries an `Idempotency-Key` — yours (`{ idempotencyKey }`) or a fresh one that is
27
+ **reused across that call's retries**. So when a request times out and is retried, the API does
28
+ it once: a refund is never made twice, a payment never charged twice.
29
+
30
+ The SDK retries network errors, timeouts, `429` (honouring `Retry-After`), `5xx` and `409
31
+ idempotency_in_progress` — twice by default, with jittered backoff — and never a `4xx` that the
32
+ same request would get again.
33
+
34
+ ```ts
35
+ new Tuppence(key, { maxRetries: 2, timeoutMs: 30_000, apiVersion: "2026-09-20" });
36
+ ```
37
+
38
+ ## Another API address
39
+
40
+ The SDK calls `https://api.tuppence.ai`. To use another Tuppence environment you have been given,
41
+ set `TUPPENCE_API_URL`, or pass `baseUrl`:
42
+
43
+ ```ts
44
+ new Tuppence(key, { baseUrl: "https://…" });
45
+ ```
46
+
47
+ ## Errors
48
+
49
+ ```ts
50
+ import { CardError, InvalidRequestError } from "@tuppence/node";
51
+
52
+ try {
53
+ await tuppence.payments.create({ amount: 5, currency: "gbp" });
54
+ } catch (error) {
55
+ if (error instanceof InvalidRequestError) {
56
+ console.log(error.code, error.param, error.requestId); // "amount_too_small", "amount", "req_…"
57
+ }
58
+ }
59
+ ```
60
+
61
+ `TuppenceError` → `InvalidRequestError`, `AuthenticationError`, `PermissionError`,
62
+ `RateLimitError`, `IdempotencyError`, `CardError` (`declineCode`), `ApiError`, `ConnectionError`.
63
+ `error.raw` has the whole error object (e.g. `topup_url` on `insufficient_credits`).
64
+
65
+ ## Webhooks
66
+
67
+ Pass the **raw** body — not re-serialised JSON:
68
+
69
+ ```ts
70
+ app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
71
+ const event = tuppence.webhooks.constructEvent(
72
+ req.body,
73
+ req.headers["tuppence-signature"],
74
+ process.env.TUPPENCE_WEBHOOK_SECRET!,
75
+ );
76
+ if (event.type === "payment.succeeded") { /* … */ }
77
+ res.sendStatus(200);
78
+ });
79
+ ```
80
+
81
+ A bad signature, the wrong secret or a timestamp older than five minutes throws
82
+ `SignatureVerificationError`. `tuppence.webhooks.generateTestHeader(body, secret)` signs a
83
+ payload the same way, for testing your handler.
84
+
85
+ ## Tuppence Connect: acting as your connected accounts
86
+
87
+ A platform's connected accounts are full Tuppence accounts. Make one, send its owner onboarding,
88
+ then act as it with your own key — per request, or through a client bound to it:
89
+
90
+ ```ts
91
+ const bella = await tuppence.accounts.create({ email: "bella@example.com", business_type: "individual" });
92
+ const link = await tuppence.accountLinks.create({
93
+ account: bella.id,
94
+ type: "account_onboarding",
95
+ refresh_url: "https://groomly.example/onboarding/refresh",
96
+ return_url: "https://groomly.example/onboarding/done",
97
+ });
98
+
99
+ // One request as Bella: the `Tuppence-Account` header, never part of the path.
100
+ await tuppence.payments.create(
101
+ { amount: 4500, currency: "gbp", application_fee_amount: 100 },
102
+ { tuppenceAccount: bella.id },
103
+ );
104
+
105
+ // Or a client bound to her — a new one: `tuppence` itself is unchanged.
106
+ const asBella = tuppence.as(bella.id);
107
+ await asBella.bookings.list();
108
+ ```
109
+
110
+ `tuppence.accounts.persons`, `.externalAccounts` and `.capabilities`, and `tuppence.files.create`
111
+ (a `Blob`, a buffer or a stream) are the rest of onboarding through your own forms.
112
+ `tuppence.applicationFees` (and `.refunds`) and `tuppence.transfers` (and `.reversals`) are the
113
+ money: your fees on your accounts' payments, and what you send them from your Connect balance —
114
+ `tuppence.balance.listTransactions({ balance: "connect" })` lists its every movement. A Connect
115
+ webhook's event carries `event.account`: the connected account it happened on.
116
+
117
+ ## Payouts and prices
118
+
119
+ `tuppence.payouts.settings()` says when money is paid out and what could change it;
120
+ `.instantQuote()` and `.create({ method: "instant" })` move pending money now, and
121
+ `.setNextDay({ enabled })` switches next-day payouts (UK) on or off once `next_day.available`
122
+ says so. `tuppence.account.retrievePricing()` is every price the account pays — the list's, or
123
+ one agreed with Tuppence, with its dates.
124
+
125
+ ## Types
126
+
127
+ Types are generated from the API's OpenAPI document, so they match the API exactly — a
128
+ misspelt field is a compile error, not a silently ignored parameter.
129
+
130
+ ## Anything else
131
+
132
+ `tuppence.request("GET", "/v1/…")` calls any endpoint with the same auth, retries and idempotency.
@@ -0,0 +1,36 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ /**
3
+ * Agent identity (plan 20-06), the signing half. An agent holds a private key Tuppence never
4
+ * sees, and signs a short-lived PROOF for each request: this method, this URL, this body, now.
5
+ * Whoever receives it checks it against the public key Tuppence publishes for that agent
6
+ * (`verifyAgentProof` in `@tuppence/http402`).
7
+ *
8
+ * A proof is not a credential to keep: it is worth one request, for seconds. Sign a fresh one
9
+ * per call — that is the point of it.
10
+ */
11
+ /** A private key: a JWK (what `credentials.create` hands back once), PEM, or a KeyObject. */
12
+ export type AgentPrivateKey = KeyObject | string | {
13
+ kty: string;
14
+ crv: string;
15
+ d: string;
16
+ x: string;
17
+ };
18
+ export type SignAgentProofInput = {
19
+ /** The agent this proves: `agt_…`. */
20
+ agent: string;
21
+ /** The key's id, as Tuppence publishes it. */
22
+ kid: string;
23
+ privateKey: AgentPrivateKey;
24
+ method: string;
25
+ url: string;
26
+ /** The exact bytes you will send, if any: the proof is bound to them. */
27
+ body?: string | Uint8Array;
28
+ /** Bind the proof to whoever it is for: a host, a merchant id, anything you both agree. */
29
+ audience?: string;
30
+ /** Default 60. A proof should be worth seconds, not minutes. */
31
+ lifetimeSeconds?: number;
32
+ nowSeconds?: number;
33
+ };
34
+ export declare function signAgentProof(input: SignAgentProofInput): string;
35
+ /** The header agents put a proof in. */
36
+ export declare const AGENT_PROOF_HEADER = "Tuppence-Agent-Proof";
@@ -0,0 +1,35 @@
1
+ import { createHash, createPrivateKey, randomUUID, sign } from "node:crypto";
2
+ const b64url = (value) => (Buffer.isBuffer(value) ? value : Buffer.from(JSON.stringify(value))).toString("base64url");
3
+ function keyOf(key) {
4
+ if (typeof key === "string")
5
+ return createPrivateKey(key);
6
+ if (typeof key === "object" && "d" in key)
7
+ return createPrivateKey({ key, format: "jwk" });
8
+ return key;
9
+ }
10
+ /** The URL the proof is bound to: no fragment, and no trailing "?" for an empty query. */
11
+ function canonical(url) {
12
+ const parsed = new URL(url);
13
+ parsed.hash = "";
14
+ return parsed.search === "?" ? parsed.toString().replace(/\?$/, "") : parsed.toString();
15
+ }
16
+ export function signAgentProof(input) {
17
+ const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
18
+ const header = { alg: "EdDSA", typ: "agent-proof+jwt", kid: input.kid };
19
+ const claims = {
20
+ iss: input.agent,
21
+ htm: input.method.toUpperCase(),
22
+ htu: canonical(input.url),
23
+ iat: now,
24
+ exp: now + (input.lifetimeSeconds ?? 60),
25
+ jti: randomUUID(),
26
+ ...(input.body === undefined
27
+ ? {}
28
+ : { bh: createHash("sha256").update(input.body).digest("base64url") }),
29
+ ...(input.audience === undefined ? {} : { aud: input.audience }),
30
+ };
31
+ const signed = `${b64url(header)}.${b64url(claims)}`;
32
+ return `${signed}.${b64url(sign(null, Buffer.from(signed), keyOf(input.privateKey)))}`;
33
+ }
34
+ /** The header agents put a proof in. */
35
+ export const AGENT_PROOF_HEADER = "Tuppence-Agent-Proof";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Every failure the API reports, as something you can `catch` and branch on. `code` is stable
3
+ * (the API only ever adds codes); `message` is for people. Quote `requestId` to support.
4
+ */
5
+ export type ErrorBody = {
6
+ type?: string;
7
+ code?: string;
8
+ message?: string;
9
+ param?: string;
10
+ decline_code?: string;
11
+ request_id?: string;
12
+ [key: string]: unknown;
13
+ };
14
+ export declare class TuppenceError extends Error {
15
+ readonly type: string;
16
+ readonly code: string;
17
+ readonly status: number | undefined;
18
+ readonly param: string | undefined;
19
+ readonly declineCode: string | undefined;
20
+ readonly requestId: string | undefined;
21
+ /**
22
+ * The whole error object — e.g. `balance` and `topup_url` on `insufficient_credits`; `limit`,
23
+ * `used`, `max`, `resets_at` and `increase_url` on `issuing_card_limit`; `missing_evidence` on
24
+ * `dispute_evidence_incomplete`; `country`, `country_label`, `coming_soon` and `waitlist_url` on
25
+ * `country_not_supported` and `region_unavailable`.
26
+ */
27
+ readonly raw: ErrorBody;
28
+ constructor(body: ErrorBody, status?: number);
29
+ /** The right subclass for an error envelope. */
30
+ static from(body: ErrorBody, status?: number): TuppenceError;
31
+ }
32
+ export declare class InvalidRequestError extends TuppenceError {
33
+ }
34
+ export declare class AuthenticationError extends TuppenceError {
35
+ }
36
+ export declare class PermissionError extends TuppenceError {
37
+ }
38
+ export declare class RateLimitError extends TuppenceError {
39
+ }
40
+ export declare class IdempotencyError extends TuppenceError {
41
+ }
42
+ /** The card was declined, needs authenticating, or the payment could not go ahead. */
43
+ export declare class CardError extends TuppenceError {
44
+ }
45
+ export declare class ApiError extends TuppenceError {
46
+ }
47
+ /** The request never got an answer: a network failure or a timeout, after every retry. */
48
+ export declare class ConnectionError extends TuppenceError {
49
+ constructor(message: string, cause?: unknown);
50
+ }
51
+ /** A webhook whose signature does not check out. Never act on its payload. */
52
+ export declare class SignatureVerificationError extends TuppenceError {
53
+ constructor(code: string, message: string);
54
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,67 @@
1
+ export class TuppenceError extends Error {
2
+ type;
3
+ code;
4
+ status;
5
+ param;
6
+ declineCode;
7
+ requestId;
8
+ /**
9
+ * The whole error object — e.g. `balance` and `topup_url` on `insufficient_credits`; `limit`,
10
+ * `used`, `max`, `resets_at` and `increase_url` on `issuing_card_limit`; `missing_evidence` on
11
+ * `dispute_evidence_incomplete`; `country`, `country_label`, `coming_soon` and `waitlist_url` on
12
+ * `country_not_supported` and `region_unavailable`.
13
+ */
14
+ raw;
15
+ constructor(body, status) {
16
+ super(body.message ?? "The Tuppence API returned an error.");
17
+ this.name = new.target.name;
18
+ this.type = body.type ?? "api_error";
19
+ this.code = body.code ?? "unknown_error";
20
+ this.status = status;
21
+ this.param = body.param;
22
+ this.declineCode = body.decline_code;
23
+ this.requestId = body.request_id;
24
+ this.raw = body;
25
+ }
26
+ /** The right subclass for an error envelope. */
27
+ static from(body, status) {
28
+ const Kind = {
29
+ invalid_request_error: InvalidRequestError,
30
+ authentication_error: AuthenticationError,
31
+ permission_error: PermissionError,
32
+ rate_limit_error: RateLimitError,
33
+ idempotency_error: IdempotencyError,
34
+ card_error: CardError,
35
+ }[body.type ?? ""] ?? ApiError;
36
+ return new Kind(body, status);
37
+ }
38
+ }
39
+ export class InvalidRequestError extends TuppenceError {
40
+ }
41
+ export class AuthenticationError extends TuppenceError {
42
+ }
43
+ export class PermissionError extends TuppenceError {
44
+ }
45
+ export class RateLimitError extends TuppenceError {
46
+ }
47
+ export class IdempotencyError extends TuppenceError {
48
+ }
49
+ /** The card was declined, needs authenticating, or the payment could not go ahead. */
50
+ export class CardError extends TuppenceError {
51
+ }
52
+ export class ApiError extends TuppenceError {
53
+ }
54
+ /** The request never got an answer: a network failure or a timeout, after every retry. */
55
+ export class ConnectionError extends TuppenceError {
56
+ constructor(message, cause) {
57
+ super({ type: "connection_error", code: "connection_failed", message });
58
+ if (cause !== undefined)
59
+ this.cause = cause;
60
+ }
61
+ }
62
+ /** A webhook whose signature does not check out. Never act on its payload. */
63
+ export class SignatureVerificationError extends TuppenceError {
64
+ constructor(code, message) {
65
+ super({ type: "signature_verification_error", code, message });
66
+ }
67
+ }