@sweepconsole/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/LICENSE +21 -0
- package/README.md +190 -2
- package/dist/client.d.ts +147 -0
- package/dist/client.js +433 -0
- package/dist/errors.d.ts +79 -0
- package/dist/errors.js +104 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +59 -0
- package/dist/money.d.ts +24 -0
- package/dist/money.js +42 -0
- package/dist/types.d.ts +375 -0
- package/dist/types.js +8 -0
- package/dist/webhooks.d.ts +79 -0
- package/dist/webhooks.js +110 -0
- package/package.json +49 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sweepconsole/node — charge a USDC wallet from your own app.
|
|
3
|
+
*
|
|
4
|
+
* The whole integration is four calls:
|
|
5
|
+
*
|
|
6
|
+
* import { Sweep, usdc } from "@sweepconsole/node";
|
|
7
|
+
* const sweep = new Sweep(process.env.SWEEP_API_KEY!);
|
|
8
|
+
*
|
|
9
|
+
* // 1 — when a user subscribes
|
|
10
|
+
* const mandate = await sweep.mandates.create({
|
|
11
|
+
* externalRef: user.id,
|
|
12
|
+
* email: user.email,
|
|
13
|
+
* maxAmount: usdc("15.00"), // the CEILING, not the price
|
|
14
|
+
* interval: "monthly",
|
|
15
|
+
* chains: ["base", "arbitrum", "optimism"],
|
|
16
|
+
* expiresAt: new Date("2027-01-01"),
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // 2 — send them to sign it
|
|
20
|
+
* res.redirect(mandate.authorizationUrl!);
|
|
21
|
+
*
|
|
22
|
+
* // 3 — react to what happens (mount with express.raw)
|
|
23
|
+
* app.post("/webhooks/sweep", express.raw({ type: "application/json" }),
|
|
24
|
+
* sweep.webhooks.express(process.env.SWEEP_WEBHOOK_SECRET!, {
|
|
25
|
+
* "mandate.authorized": (e) => db.users.activate(e.externalRef),
|
|
26
|
+
* "charge.succeeded": (e) => db.users.extend(e.externalRef),
|
|
27
|
+
* }));
|
|
28
|
+
*
|
|
29
|
+
* // 4 — charge on your own clock
|
|
30
|
+
* await sweep.charges.create(
|
|
31
|
+
* { mandate: user.sweepMandate, amount: usdc("9.00"), description: "Pro — September" },
|
|
32
|
+
* { idempotencyKey: `${user.id}:2027-01` },
|
|
33
|
+
* );
|
|
34
|
+
*
|
|
35
|
+
* Sweep owns no schedule. Your app decides when to charge; this moves the money
|
|
36
|
+
* and enforces the ceiling the payer signed.
|
|
37
|
+
*/
|
|
38
|
+
import { Sweep as SweepClient } from "./client.js";
|
|
39
|
+
import { construct, expressHandler, verify } from "./webhooks.js";
|
|
40
|
+
export { usdc, format } from "./money.js";
|
|
41
|
+
export { AmountOverCap, ChargeConflict, IdempotencyKeyInFlight, IdempotencyKeyRequired, IdempotencyKeyReused, MandateExists, MandateNotActive, MandateNotFound, MandateRevoked, PeriodCapExceeded, RailNotEnabled, SweepError, WebhookSignatureError, } from "./errors.js";
|
|
42
|
+
/// The client, with `webhooks` attached. Verification needs no API key — it is
|
|
43
|
+
/// a function of the raw body and your endpoint's signing secret — but hanging
|
|
44
|
+
/// it here means one import and one object to find it on.
|
|
45
|
+
export class Sweep extends SweepClient {
|
|
46
|
+
// Inferred, not re-declared. The hand-written shape that used to sit here
|
|
47
|
+
// restated express's handler map as Partial<Record<WebhookEventType, …>>,
|
|
48
|
+
// which widened every handler's event back to the untyped one — on the path
|
|
49
|
+
// the README actually documents.
|
|
50
|
+
webhooks = {
|
|
51
|
+
verify,
|
|
52
|
+
construct,
|
|
53
|
+
express: expressHandler,
|
|
54
|
+
};
|
|
55
|
+
constructor(apiKey, options = {}) {
|
|
56
|
+
super(apiKey, options);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export default Sweep;
|
package/dist/money.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Usdc } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The single most common way to lose money on this rail is a factor of 10⁶.
|
|
4
|
+
*
|
|
5
|
+
* `max_amount` and `amount` are USDC micro-units: 5000000 is 5 USDC. Written as
|
|
6
|
+
* a bare number that is one keystroke away from a payer signing a ceiling a
|
|
7
|
+
* million times too large, or a charge of nine ten-thousandths of a cent. The
|
|
8
|
+
* API cannot catch either — both are valid integers.
|
|
9
|
+
*
|
|
10
|
+
* So amounts are a branded type, and this is the only way to make one. A plain
|
|
11
|
+
* number will not compile where a `Usdc` is expected, which moves the whole
|
|
12
|
+
* class of mistake from production to your editor.
|
|
13
|
+
*
|
|
14
|
+
* usdc("9.00") // 9000000
|
|
15
|
+
* usdc("0.5") // 500000
|
|
16
|
+
* usdc(9) // 9000000 — whole dollars only, for convenience
|
|
17
|
+
*
|
|
18
|
+
* Strings are preferred: 0.1 + 0.2 is not 0.3 in binary floating point, and a
|
|
19
|
+
* price that arrives from a database as a decimal string should never be routed
|
|
20
|
+
* through a float on its way here.
|
|
21
|
+
*/
|
|
22
|
+
export declare function usdc(amount: string | number): Usdc;
|
|
23
|
+
/** The inverse, for display: `format(9000000)` → `"9.00"`. */
|
|
24
|
+
export declare function format(amount: Usdc | number): string;
|
package/dist/money.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single most common way to lose money on this rail is a factor of 10⁶.
|
|
3
|
+
*
|
|
4
|
+
* `max_amount` and `amount` are USDC micro-units: 5000000 is 5 USDC. Written as
|
|
5
|
+
* a bare number that is one keystroke away from a payer signing a ceiling a
|
|
6
|
+
* million times too large, or a charge of nine ten-thousandths of a cent. The
|
|
7
|
+
* API cannot catch either — both are valid integers.
|
|
8
|
+
*
|
|
9
|
+
* So amounts are a branded type, and this is the only way to make one. A plain
|
|
10
|
+
* number will not compile where a `Usdc` is expected, which moves the whole
|
|
11
|
+
* class of mistake from production to your editor.
|
|
12
|
+
*
|
|
13
|
+
* usdc("9.00") // 9000000
|
|
14
|
+
* usdc("0.5") // 500000
|
|
15
|
+
* usdc(9) // 9000000 — whole dollars only, for convenience
|
|
16
|
+
*
|
|
17
|
+
* Strings are preferred: 0.1 + 0.2 is not 0.3 in binary floating point, and a
|
|
18
|
+
* price that arrives from a database as a decimal string should never be routed
|
|
19
|
+
* through a float on its way here.
|
|
20
|
+
*/
|
|
21
|
+
export function usdc(amount) {
|
|
22
|
+
const text = typeof amount === "number" ? String(amount) : amount.trim();
|
|
23
|
+
if (!/^\d+(\.\d{1,6})?$/.test(text)) {
|
|
24
|
+
throw new RangeError(`usdc(): expected a positive amount with at most 6 decimal places, got ${JSON.stringify(amount)}. ` +
|
|
25
|
+
`Examples: usdc("9.00"), usdc("0.5"), usdc(9).`);
|
|
26
|
+
}
|
|
27
|
+
const [whole = "0", fraction = ""] = text.split(".");
|
|
28
|
+
const micro = BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, "0"));
|
|
29
|
+
if (micro <= 0n)
|
|
30
|
+
throw new RangeError("usdc(): amount must be greater than zero.");
|
|
31
|
+
if (micro > BigInt(Number.MAX_SAFE_INTEGER))
|
|
32
|
+
throw new RangeError("usdc(): amount is too large.");
|
|
33
|
+
return Number(micro);
|
|
34
|
+
}
|
|
35
|
+
/** The inverse, for display: `format(9000000)` → `"9.00"`. */
|
|
36
|
+
export function format(amount) {
|
|
37
|
+
const negative = amount < 0;
|
|
38
|
+
const micro = BigInt(Math.abs(Math.trunc(amount)));
|
|
39
|
+
const whole = micro / 1000000n;
|
|
40
|
+
const fraction = (micro % 1000000n).toString().padStart(6, "0").replace(/0+$/, "").padEnd(2, "0");
|
|
41
|
+
return `${negative ? "-" : ""}${whole}.${fraction}`;
|
|
42
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/** USDC in micro-units (6 decimals). Build one with `usdc()` — see money.ts. */
|
|
2
|
+
export type Usdc = number & {
|
|
3
|
+
readonly __brand: "usdc-micro";
|
|
4
|
+
};
|
|
5
|
+
export type Interval = "daily" | "weekly" | "monthly" | "yearly";
|
|
6
|
+
/** Chains a payer may authorize. `arc` is refused: it is the settlement chain. */
|
|
7
|
+
export type Chain = "base" | "arbitrum" | "optimism";
|
|
8
|
+
export type MandateStatus = "pending" | "active" | "revoked" | "expired";
|
|
9
|
+
export type ChargeStatus = "pending" | "succeeded" | "failed";
|
|
10
|
+
/** Why a charge that was accepted could not be collected. Arrives by webhook. */
|
|
11
|
+
export type FailureCode = "insufficient_funds" | "mandate_period_consumed" | "mandate_expired" | "no_payout_wallet";
|
|
12
|
+
export interface Mandate {
|
|
13
|
+
id: string;
|
|
14
|
+
mode: "external";
|
|
15
|
+
status: MandateStatus;
|
|
16
|
+
externalRef: string;
|
|
17
|
+
/** What YOU sent. Never overwritten — see `verifiedEmail`. */
|
|
18
|
+
email: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* The address the payer actually proved on the authorization page, by
|
|
21
|
+
* answering a code sent to it. Null until they have.
|
|
22
|
+
*
|
|
23
|
+
* It need not equal `email`. Yours is prefilled for them and they may change
|
|
24
|
+
* it, because an address you hold is eventually a stale one and pinning it
|
|
25
|
+
* would turn that into a payer who cannot authorize at all. When the two
|
|
26
|
+
* differ, this is the inbox a human answered: receipts go here, and it is
|
|
27
|
+
* what links them to a Sweep customer record.
|
|
28
|
+
*/
|
|
29
|
+
verifiedEmail: string | null;
|
|
30
|
+
/** Null until the payer signs — which wallet authorizes is unknown before then. */
|
|
31
|
+
walletAddress: string | null;
|
|
32
|
+
maxAmount: Usdc;
|
|
33
|
+
currency: "USDC";
|
|
34
|
+
interval: Interval;
|
|
35
|
+
periodDuration: number;
|
|
36
|
+
chains: string[];
|
|
37
|
+
testMode: boolean;
|
|
38
|
+
/** When the authorization stops being redeemable. */
|
|
39
|
+
expiresAt: Date;
|
|
40
|
+
/** Where to send the payer. Null once the mandate is no longer pending. */
|
|
41
|
+
authorizationUrl: string | null;
|
|
42
|
+
/** The LINK's lifetime (24h), not the mandate's. Do not conflate them. */
|
|
43
|
+
authorizationUrlExpiresAt: Date;
|
|
44
|
+
authorizedAt: Date | null;
|
|
45
|
+
revokedAt: Date | null;
|
|
46
|
+
metadata: Record<string, unknown> | null;
|
|
47
|
+
createdAt: Date;
|
|
48
|
+
}
|
|
49
|
+
export interface Charge {
|
|
50
|
+
id: string;
|
|
51
|
+
mandate: string;
|
|
52
|
+
status: ChargeStatus;
|
|
53
|
+
amount: Usdc;
|
|
54
|
+
currency: "USDC";
|
|
55
|
+
description: string | null;
|
|
56
|
+
externalRef: string;
|
|
57
|
+
metadata: Record<string, unknown> | null;
|
|
58
|
+
testMode: boolean;
|
|
59
|
+
/** The chain the funds came FROM. */
|
|
60
|
+
sourceChain: string | null;
|
|
61
|
+
/** Always "arc" once settled — tx_hash is the Arc mint, not a source-chain tx. */
|
|
62
|
+
settlementChain: "arc" | null;
|
|
63
|
+
txHash: string | null;
|
|
64
|
+
failureReason: string | null;
|
|
65
|
+
createdAt: Date;
|
|
66
|
+
settledAt: Date | null;
|
|
67
|
+
}
|
|
68
|
+
export interface CreateMandateParams {
|
|
69
|
+
/** YOUR id for this payer. Echoed on every webhook, so you never store ours. */
|
|
70
|
+
externalRef: string;
|
|
71
|
+
/**
|
|
72
|
+
* Prefilled on the authorization page, and the payer may replace it. Omit and
|
|
73
|
+
* they supply their own — what they prove comes back as `verifiedEmail`, and
|
|
74
|
+
* receipts go there. Omitting it only costs you a receipt if they never
|
|
75
|
+
* verify either.
|
|
76
|
+
*/
|
|
77
|
+
email?: string;
|
|
78
|
+
/** The ceiling per interval — NOT the price. Give it headroom. */
|
|
79
|
+
maxAmount: Usdc;
|
|
80
|
+
interval: Interval;
|
|
81
|
+
chains: Chain[];
|
|
82
|
+
expiresAt: Date;
|
|
83
|
+
returnUrl?: string;
|
|
84
|
+
metadata?: Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
export interface CreateChargeParams {
|
|
87
|
+
/** A mandate id (`mdt_…`), or a Mandate. */
|
|
88
|
+
mandate: string | Mandate;
|
|
89
|
+
amount: Usdc;
|
|
90
|
+
/** The ONLY text the payer sees on their receipt. Write it for them. */
|
|
91
|
+
description?: string;
|
|
92
|
+
metadata?: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
export type SubscriptionStatus = "active" | "trialing" | "past_due" | "cancelled" | "incomplete";
|
|
95
|
+
/** A subscription to a plan you made in the portal (or through /v1/plans). */
|
|
96
|
+
export interface Subscription {
|
|
97
|
+
id: string;
|
|
98
|
+
/** YOUR id for this payer, if you set one when the session was created. */
|
|
99
|
+
externalRef: string;
|
|
100
|
+
status: SubscriptionStatus;
|
|
101
|
+
/** The wallet that pays. Null before the first charge settles. */
|
|
102
|
+
walletAddress: string | null;
|
|
103
|
+
activationMethod: string;
|
|
104
|
+
testMode: boolean;
|
|
105
|
+
plan: {
|
|
106
|
+
id: string;
|
|
107
|
+
name: string;
|
|
108
|
+
amount: Usdc;
|
|
109
|
+
currency: "USDC";
|
|
110
|
+
interval: Interval;
|
|
111
|
+
};
|
|
112
|
+
txHash: string | null;
|
|
113
|
+
currentPeriodStart: Date;
|
|
114
|
+
/** When the next charge is due. Billing moves this forward on each renewal. */
|
|
115
|
+
currentPeriodEnd: Date;
|
|
116
|
+
trialStart: Date | null;
|
|
117
|
+
trialEnd: Date | null;
|
|
118
|
+
cancelledAt: Date | null;
|
|
119
|
+
createdAt: Date;
|
|
120
|
+
updatedAt: Date;
|
|
121
|
+
}
|
|
122
|
+
export interface CreateSessionParams {
|
|
123
|
+
/** A plan id (`plan_…`) from the portal, or a Plan you created. */
|
|
124
|
+
plan: string;
|
|
125
|
+
/**
|
|
126
|
+
* YOUR id for this payer. It arrives as `external_ref` on every webhook for
|
|
127
|
+
* the subscription this session creates, which is how you map it back. Pass
|
|
128
|
+
* it from your own session, never from the browser.
|
|
129
|
+
*/
|
|
130
|
+
externalRef: string;
|
|
131
|
+
successUrl: string;
|
|
132
|
+
cancelUrl: string;
|
|
133
|
+
metadata?: Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
/** A hosted checkout session. Send the payer to `url`. */
|
|
136
|
+
export interface CheckoutSession {
|
|
137
|
+
id: string;
|
|
138
|
+
/** Where to send the payer. This is the whole point of the object. */
|
|
139
|
+
url: string;
|
|
140
|
+
status: string;
|
|
141
|
+
/**
|
|
142
|
+
* For the embedded checkout. Not a secret you can treat casually: it
|
|
143
|
+
* authorizes completing THIS session, so it belongs in the page that is
|
|
144
|
+
* checking out and nowhere else.
|
|
145
|
+
*/
|
|
146
|
+
sessionToken?: string;
|
|
147
|
+
plan: {
|
|
148
|
+
name: string;
|
|
149
|
+
amount: Usdc;
|
|
150
|
+
currency: "USDC";
|
|
151
|
+
interval: Interval;
|
|
152
|
+
};
|
|
153
|
+
expiresAt: Date;
|
|
154
|
+
}
|
|
155
|
+
/** A session read back after the fact. Has no `url` or token. */
|
|
156
|
+
export interface RetrievedSession {
|
|
157
|
+
id: string;
|
|
158
|
+
status: string;
|
|
159
|
+
externalRef: string;
|
|
160
|
+
successUrl: string;
|
|
161
|
+
cancelUrl: string;
|
|
162
|
+
metadata: Record<string, unknown> | null;
|
|
163
|
+
testMode: boolean;
|
|
164
|
+
plan: {
|
|
165
|
+
name: string;
|
|
166
|
+
amount: Usdc;
|
|
167
|
+
currency: "USDC";
|
|
168
|
+
interval: Interval;
|
|
169
|
+
};
|
|
170
|
+
/** Set once checkout completed. */
|
|
171
|
+
subscriptionId: string | null;
|
|
172
|
+
expiresAt: Date;
|
|
173
|
+
createdAt: Date;
|
|
174
|
+
}
|
|
175
|
+
export type WebhookEventType = "checkout.session.completed" | "subscription.created" | "subscription.renewed" | "subscription.past_due" | "subscription.cancelled" | "payment.succeeded" | "payment.failed" | "mandate.authorized" | "mandate.revoked" | "mandate.expiring" | "mandate.expired" | "charge.succeeded" | "charge.failed";
|
|
176
|
+
/**
|
|
177
|
+
* What each event carries.
|
|
178
|
+
*
|
|
179
|
+
* These were `Record<string, unknown>` — every field a developer needed was
|
|
180
|
+
* reachable only by guessing its name and casting. The names are snake_case
|
|
181
|
+
* because this is the wire payload, unlike the rest of the SDK.
|
|
182
|
+
*/
|
|
183
|
+
export interface WebhookPayloads {
|
|
184
|
+
"checkout.session.completed": {
|
|
185
|
+
subscription_id: string;
|
|
186
|
+
plan_id: string;
|
|
187
|
+
amount: number;
|
|
188
|
+
/** What reached your wallet, after the platform fee. */
|
|
189
|
+
merchant_share: number;
|
|
190
|
+
platform_fee: number;
|
|
191
|
+
currency: "USDC";
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* A subscriber finished checkout on a hosted plan and billing began.
|
|
195
|
+
*
|
|
196
|
+
* This is the one to act on for a portal-created plan. Note what identifies
|
|
197
|
+
* the payer: with a payment link you did not set an `external_ref`, so the
|
|
198
|
+
* envelope's is one Sweep generated. Use `subscriber_email` or the stable
|
|
199
|
+
* `customer_id` — or append `?ref=<your user id>` to the link, which the
|
|
200
|
+
* hosted page forwards.
|
|
201
|
+
*/
|
|
202
|
+
"subscription.created": {
|
|
203
|
+
subscription_id: string;
|
|
204
|
+
plan_id: string;
|
|
205
|
+
plan_name: string;
|
|
206
|
+
tier_id: string;
|
|
207
|
+
tier_name: string;
|
|
208
|
+
amount: number;
|
|
209
|
+
currency: "USDC";
|
|
210
|
+
interval: Interval;
|
|
211
|
+
status: string;
|
|
212
|
+
activation_method: string;
|
|
213
|
+
wallet_address: string;
|
|
214
|
+
/** Stable across every wallet this person pays from. Null if unresolved. */
|
|
215
|
+
customer_id: string | null;
|
|
216
|
+
subscriber_email: string;
|
|
217
|
+
tx_hash: string | null;
|
|
218
|
+
allowance_tx_hash: string | null;
|
|
219
|
+
block_number: number | null;
|
|
220
|
+
/** Where the USDC came from. */
|
|
221
|
+
chain: string;
|
|
222
|
+
/** Where it settled. Always Arc. */
|
|
223
|
+
settlement_chain: string;
|
|
224
|
+
};
|
|
225
|
+
"subscription.renewed": {
|
|
226
|
+
subscription_id: string;
|
|
227
|
+
plan_id: string;
|
|
228
|
+
amount: number;
|
|
229
|
+
currency: "USDC";
|
|
230
|
+
tx_hash: string;
|
|
231
|
+
chain: string;
|
|
232
|
+
source_chain: string;
|
|
233
|
+
/** When the next charge is due. */
|
|
234
|
+
current_period_end: string;
|
|
235
|
+
};
|
|
236
|
+
/** A renewal failed and the subscription is awaiting payment. */
|
|
237
|
+
"subscription.past_due": {
|
|
238
|
+
subscription_id: string;
|
|
239
|
+
plan_id: string;
|
|
240
|
+
amount: number;
|
|
241
|
+
currency: "USDC";
|
|
242
|
+
/** Which retry this was. */
|
|
243
|
+
attempt: number;
|
|
244
|
+
reason: string;
|
|
245
|
+
};
|
|
246
|
+
"subscription.cancelled": {
|
|
247
|
+
subscription_id: string;
|
|
248
|
+
plan_id: string;
|
|
249
|
+
cancel_reason: string;
|
|
250
|
+
cancelled_at: string;
|
|
251
|
+
/** Only when the payer's own cancel produced it. */
|
|
252
|
+
wallet_address?: string;
|
|
253
|
+
revoked_delegations?: number;
|
|
254
|
+
};
|
|
255
|
+
"payment.succeeded": {
|
|
256
|
+
subscription_id: string;
|
|
257
|
+
plan_id?: string;
|
|
258
|
+
amount: number;
|
|
259
|
+
/** What reached your wallet, after the platform fee. */
|
|
260
|
+
merchant_share?: number;
|
|
261
|
+
platform_fee?: number;
|
|
262
|
+
currency: "USDC";
|
|
263
|
+
/** "initial" for the first charge, "renewal" afterwards. */
|
|
264
|
+
type: string;
|
|
265
|
+
tx_hash: string | null;
|
|
266
|
+
};
|
|
267
|
+
"payment.failed": {
|
|
268
|
+
subscription_id: string;
|
|
269
|
+
plan_id: string;
|
|
270
|
+
amount: number;
|
|
271
|
+
currency: "USDC";
|
|
272
|
+
type: string;
|
|
273
|
+
attempt: number;
|
|
274
|
+
reason: string;
|
|
275
|
+
};
|
|
276
|
+
"mandate.authorized": {
|
|
277
|
+
mandate_id: string;
|
|
278
|
+
external_ref: string;
|
|
279
|
+
/** The wallet that signed. Known for the first time at this event. */
|
|
280
|
+
wallet_address: string;
|
|
281
|
+
max_amount: number;
|
|
282
|
+
currency: "USDC";
|
|
283
|
+
interval: Interval;
|
|
284
|
+
/** Numeric chain ids actually signed — may be fewer than you requested. */
|
|
285
|
+
chain_ids: number[];
|
|
286
|
+
/** What you sent when you created it, or null. */
|
|
287
|
+
email: string | null;
|
|
288
|
+
/** What the payer proved. May differ from `email` — reconcile on this. */
|
|
289
|
+
verified_email: string | null;
|
|
290
|
+
expires_at: string;
|
|
291
|
+
/**
|
|
292
|
+
* This wallet already backs another live mandate at your merchant.
|
|
293
|
+
*
|
|
294
|
+
* Not an error, and not blocked: one person paying for two seats, or a
|
|
295
|
+
* household or company wallet, is legitimate, and each payer proved their
|
|
296
|
+
* own email. But the two are independent delegations with independent
|
|
297
|
+
* period enforcers, so their ceilings ADD UP rather than capping each
|
|
298
|
+
* other. If that is not something you allow, this is where you find out.
|
|
299
|
+
*/
|
|
300
|
+
wallet_reused: boolean;
|
|
301
|
+
};
|
|
302
|
+
"mandate.revoked": {
|
|
303
|
+
mandate_id: string;
|
|
304
|
+
external_ref: string;
|
|
305
|
+
wallet_address: string | null;
|
|
306
|
+
revoked_at: string;
|
|
307
|
+
/** Who ended it: your own DELETE, or the payer from their portal. */
|
|
308
|
+
revoked_by: "merchant" | "payer";
|
|
309
|
+
/**
|
|
310
|
+
* Always false. The signed permission stays in the payer's wallet —
|
|
311
|
+
* disableDelegation is onlyDeleGator, so only they can remove it. What is
|
|
312
|
+
* guaranteed is that Sweep Console will not redeem it again.
|
|
313
|
+
*/
|
|
314
|
+
on_chain: boolean;
|
|
315
|
+
};
|
|
316
|
+
"mandate.expiring": {
|
|
317
|
+
mandate_id: string;
|
|
318
|
+
external_ref: string;
|
|
319
|
+
wallet_address: string | null;
|
|
320
|
+
expires_at: string;
|
|
321
|
+
days_remaining: number;
|
|
322
|
+
recovery: string;
|
|
323
|
+
};
|
|
324
|
+
"mandate.expired": {
|
|
325
|
+
mandate_id: string;
|
|
326
|
+
external_ref: string;
|
|
327
|
+
wallet_address: string | null;
|
|
328
|
+
expired_at: string;
|
|
329
|
+
/** You cannot extend a mandate. Only the payer can sign a new one. */
|
|
330
|
+
recovery: string;
|
|
331
|
+
};
|
|
332
|
+
"charge.succeeded": {
|
|
333
|
+
charge_id: string;
|
|
334
|
+
mandate_id: string;
|
|
335
|
+
external_ref: string;
|
|
336
|
+
amount: number;
|
|
337
|
+
currency: "USDC";
|
|
338
|
+
/** Where the USDC came from. */
|
|
339
|
+
source_chain: string;
|
|
340
|
+
/** Where it settled. Always Arc. */
|
|
341
|
+
chain: string;
|
|
342
|
+
tx_hash: string;
|
|
343
|
+
description: string | null;
|
|
344
|
+
};
|
|
345
|
+
"charge.failed": {
|
|
346
|
+
charge_id: string;
|
|
347
|
+
mandate_id: string;
|
|
348
|
+
external_ref: string;
|
|
349
|
+
amount: number;
|
|
350
|
+
currency: "USDC";
|
|
351
|
+
failure_code: string;
|
|
352
|
+
failure_reason: string;
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
export interface WebhookEvent<T = Record<string, unknown>> {
|
|
356
|
+
eventId: string;
|
|
357
|
+
eventType: WebhookEventType;
|
|
358
|
+
createdAt: Date;
|
|
359
|
+
merchantId: string;
|
|
360
|
+
/** The `externalRef` you set on the mandate. Map it back to your own user. */
|
|
361
|
+
externalRef: string;
|
|
362
|
+
data: T;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* An event narrowed to one type, so `data` is the real shape.
|
|
366
|
+
*
|
|
367
|
+
* The handler map in `expressHandler` hands you one of these already narrowed;
|
|
368
|
+
* `construct` cannot, since the type is only known after it parses.
|
|
369
|
+
*/
|
|
370
|
+
export type TypedWebhookEvent<K extends WebhookEventType = WebhookEventType> = {
|
|
371
|
+
[T in K]: Omit<WebhookEvent, "eventType" | "data"> & {
|
|
372
|
+
eventType: T;
|
|
373
|
+
data: WebhookPayloads[T];
|
|
374
|
+
};
|
|
375
|
+
}[K];
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The wire shapes, as the API actually serializes them.
|
|
2
|
+
//
|
|
3
|
+
// Field names here are camelCase while the API speaks snake_case. That is the
|
|
4
|
+
// one liberty this client takes with the protocol, and it is deliberate: the
|
|
5
|
+
// alternative is a developer writing `external_ref` in a TypeScript file where
|
|
6
|
+
// every other property is camelCase, getting it wrong, and finding out from a
|
|
7
|
+
// 422 rather than from their editor.
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { WebhookEvent, WebhookEventType, TypedWebhookEvent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Webhook verification, which is where this integration goes wrong most often.
|
|
4
|
+
*
|
|
5
|
+
* Three traps, all of them silent, all of them producing the same "bad
|
|
6
|
+
* signature" symptom:
|
|
7
|
+
*
|
|
8
|
+
* 1. The HMAC is over the RAW bytes. If `express.json()` has already parsed
|
|
9
|
+
* the body, re-serializing it gives different bytes — key order, spacing,
|
|
10
|
+
* unicode escapes — and nothing you do downstream can recover them.
|
|
11
|
+
* 2. The header is `sha256=<hex>`, not bare hex. Comparing the two fails
|
|
12
|
+
* forever, and the failure looks exactly like a wrong secret.
|
|
13
|
+
* 3. The comparison must be constant-time, or it leaks the expected value a
|
|
14
|
+
* byte at a time to anyone willing to send enough requests.
|
|
15
|
+
*
|
|
16
|
+
* `verify` handles all three. Give it the raw body as a Buffer or string.
|
|
17
|
+
*/
|
|
18
|
+
export declare function verify(rawBody: Buffer | string, signature: string | undefined, secret: string): void;
|
|
19
|
+
export interface ConstructOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Reject an event whose `created_at` is older than this many seconds.
|
|
22
|
+
*
|
|
23
|
+
* OFF by default, and that is deliberate: deliveries here retry at 5, 30,
|
|
24
|
+
* 120, 300 and 600 minutes with the original payload, so any tolerance
|
|
25
|
+
* shorter than about 11 hours would reject the platform's own retries — the
|
|
26
|
+
* five-minute window other rails use does not fit this delivery schedule.
|
|
27
|
+
*
|
|
28
|
+
* `created_at` is inside the signed body, so it cannot be edited by whoever
|
|
29
|
+
* replays it. That makes this a real check if you want one; it is just not a
|
|
30
|
+
* substitute for the next paragraph.
|
|
31
|
+
*
|
|
32
|
+
* REPLAY DEFENCE: dedupe on `eventId`. It is stable across every retry of the
|
|
33
|
+
* same event, so storing the ones you have processed makes a replayed body
|
|
34
|
+
* harmless however old it is, and makes your handler safe to run twice —
|
|
35
|
+
* which it must be regardless, since a retry is not an attack.
|
|
36
|
+
*/
|
|
37
|
+
toleranceSeconds?: number;
|
|
38
|
+
}
|
|
39
|
+
/** Verify, then parse. Returns the event with Dates and camelCase fields. */
|
|
40
|
+
export declare function construct(rawBody: Buffer | string, signature: string | undefined, secret: string, opts?: ConstructOptions): WebhookEvent;
|
|
41
|
+
/**
|
|
42
|
+
* Each handler receives its own event type, so `event.data` is the payload for
|
|
43
|
+
* that event rather than Record<string, unknown>. A typo in a field name is a
|
|
44
|
+
* compile error now instead of undefined at 3am.
|
|
45
|
+
*/
|
|
46
|
+
type Handler<K extends WebhookEventType> = (event: TypedWebhookEvent<K>) => void | Promise<void>;
|
|
47
|
+
/** Minimal shapes, so this package needs no dependency on Express's types. */
|
|
48
|
+
interface Req {
|
|
49
|
+
body: unknown;
|
|
50
|
+
headers: Record<string, string | string[] | undefined>;
|
|
51
|
+
}
|
|
52
|
+
interface Res {
|
|
53
|
+
status(code: number): Res;
|
|
54
|
+
send(body: string): unknown;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* An Express handler that verifies, routes, and acknowledges.
|
|
58
|
+
*
|
|
59
|
+
* Mount it with `express.raw` — the whole point is that the raw bytes survive:
|
|
60
|
+
*
|
|
61
|
+
* app.post("/webhooks/sweep",
|
|
62
|
+
* express.raw({ type: "application/json" }),
|
|
63
|
+
* sweep.webhooks.express(process.env.SWEEP_WEBHOOK_SECRET!, {
|
|
64
|
+
* "charge.succeeded": (e) => db.users.extend(e.externalRef),
|
|
65
|
+
* }));
|
|
66
|
+
*
|
|
67
|
+
* It answers 2xx before running your handler, because Sweep gives you 10
|
|
68
|
+
* seconds and retries otherwise. A handler that throws is logged, not
|
|
69
|
+
* re-raised: the event was accepted, and failing the response would earn a
|
|
70
|
+
* retry of something you have already recorded.
|
|
71
|
+
*/
|
|
72
|
+
export declare function expressHandler(secret: string, handlers: {
|
|
73
|
+
[K in WebhookEventType]?: Handler<K>;
|
|
74
|
+
} & {
|
|
75
|
+
onError?: (e: unknown) => void;
|
|
76
|
+
/** See ConstructOptions. Off by default, for the retry schedule's sake. */
|
|
77
|
+
toleranceSeconds?: number;
|
|
78
|
+
}): (req: Req, res: Res) => void;
|
|
79
|
+
export {};
|