@sweepconsole/node 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sweep Console
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
1
+ # @sweepconsole/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
+ Charge a USDC wallet from your own app. Your billing logic, your schedule — Sweep
4
+ moves the money and enforces the ceiling the payer signed.
5
+
6
+ ```bash
7
+ npm install @sweepconsole/node
8
+ ```
9
+
10
+ Requires Node 20+. You need a Sweep account with the **payment rail** enabled
11
+ (portal → Payment rail → Request access) and a payout wallet linked.
12
+
13
+ ## Two rails, one webhook endpoint
14
+
15
+ **Hosted plans** — you made the plan in the portal. Start a checkout, send the
16
+ payer to it, and act on the webhook:
17
+
18
+ ```ts
19
+ // your endpoint. externalRef comes from your session, never from the page.
20
+ app.post("/api/checkout", requireLogin, async (req, res) => {
21
+ const session = await sweep.checkout.sessions.create({
22
+ plan: "plan_pro",
23
+ externalRef: req.user.id,
24
+ successUrl: "https://app.example.com/welcome",
25
+ cancelUrl: "https://app.example.com/pricing",
26
+ });
27
+ res.json({ url: session.url });
28
+ });
29
+ ```
30
+
31
+ ```ts
32
+ // is this person a subscriber right now? null when nothing is live.
33
+ const sub = await sweep.subscriptions.status(req.user.id);
34
+
35
+ // from your admin panel
36
+ await sweep.subscriptions.cancel(sub.id, { reason: "requested by support" });
37
+ ```
38
+
39
+ **The external rail** — you create mandates and charge them yourself, with your
40
+ own billing logic. That is `sweep.mandates` and `sweep.charges` below.
41
+
42
+ Either way the webhook is what grants access. These types cover both:
43
+
44
+ ```ts
45
+ sweep.webhooks.express(process.env.SWEEP_WEBHOOK_SECRET!, {
46
+ "subscription.created": async (e) => {
47
+ // With a payment link you did not set an external_ref, so the envelope
48
+ // carries one we generated. Identify on the email, or on customer_id,
49
+ // which is stable across every wallet this person pays from.
50
+ await grantAccess(e.data.subscriber_email, e.data.plan_id);
51
+ },
52
+ "subscription.renewed": async (e) => {
53
+ await extendTo(e.data.subscription_id, e.data.current_period_end);
54
+ },
55
+ "subscription.cancelled": async (e) => {
56
+ await revoke(e.data.subscription_id, e.data.cancel_reason);
57
+ },
58
+ });
59
+ ```
60
+
61
+ A payment link needs no API call at all: the link is the checkout, and the
62
+ webhook is the whole integration. Verification is an HMAC over the raw body,
63
+ which the docs show in fifteen lines of `node:crypto` — so you can handle
64
+ hosted plans without this package. Install it for the payload types, the
65
+ dispatch, and the calls above.
66
+
67
+ ## Refunds
68
+
69
+ There is no refund method, because there is no refund. A charge settles by
70
+ minting your share straight into your wallet, so there is never a moment when
71
+ this platform holds the money and could return it. `POST /v1/subscriptions/:id/refund`
72
+ still answers, with that explanation, so an old integration gets a reason
73
+ instead of a 404. Cancel stops the next charge; returning a past one is between
74
+ you and your payer.
75
+
76
+ ## The whole integration
77
+
78
+ ```ts
79
+ import { Sweep, usdc } from "@sweepconsole/node";
80
+
81
+ const sweep = new Sweep(process.env.SWEEP_API_KEY!);
82
+ // baseUrl defaults to the hosted API; override it for a tunnel or self-hosting.
83
+ ```
84
+
85
+ **1 — when a user subscribes, create a mandate and send them to sign it**
86
+
87
+ ```ts
88
+ app.post("/subscribe/usdc", async (req, res) => {
89
+ const mandate = await sweep.mandates.create({
90
+ externalRef: req.user.id, // YOUR id — echoed on every event
91
+ email: req.user.email,
92
+ maxAmount: usdc("15.00"), // the CEILING, not the price
93
+ interval: "monthly",
94
+ chains: ["base", "arbitrum", "optimism"],
95
+ expiresAt: new Date("2027-01-01"),
96
+ returnUrl: "https://shop.example.com/thanks",
97
+ });
98
+
99
+ await db.users.update(req.user.id, { sweepMandate: mandate.id });
100
+ res.redirect(mandate.authorizationUrl!);
101
+ });
102
+ ```
103
+
104
+ **2 — react to what happens**
105
+
106
+ ```ts
107
+ app.post("/webhooks/sweep",
108
+ express.raw({ type: "application/json" }), // raw, not json — see below
109
+ sweep.webhooks.express(process.env.SWEEP_WEBHOOK_SECRET!, {
110
+ "mandate.authorized": (e) => db.users.activate(e.externalRef),
111
+ "charge.succeeded": (e) => db.users.extend(e.externalRef),
112
+ "charge.failed": (e) => db.users.dun(e.externalRef, e.data.failure_code),
113
+ "mandate.revoked": (e) => db.users.deactivate(e.externalRef),
114
+ }));
115
+ ```
116
+
117
+ **3 — charge on your own clock**
118
+
119
+ ```ts
120
+ for (const user of await db.users.dueForCharge()) {
121
+ try {
122
+ await sweep.charges.create(
123
+ { mandate: user.sweepMandate, amount: usdc("9.00"), description: "Pro plan — September" },
124
+ { idempotencyKey: `${user.id}:${thisPeriod}` },
125
+ );
126
+ } catch (e) {
127
+ if (e instanceof Sweep.PeriodCapExceeded) continue; // already collected
128
+ if (e instanceof Sweep.MandateRevoked) await db.users.deactivate(user.id);
129
+ else throw e;
130
+ }
131
+ }
132
+ ```
133
+
134
+ That's it. No plans, no schedule, no renewal engine — those stay yours.
135
+
136
+ ## The four things that trip people up
137
+
138
+ **Amounts are micro-units.** `usdc("9.00")` is 9000000. The branded return type
139
+ means a bare number will not compile where an amount is expected, so the factor
140
+ of 10⁶ cannot reach production.
141
+
142
+ **Webhook signatures are over the raw bytes.** If `express.json()` parses the
143
+ body first, the bytes you hash are not the bytes that were signed. Mount with
144
+ `express.raw`. `sweep.webhooks.express()` also handles the `sha256=` prefix and
145
+ the constant-time compare, which are the other two ways this goes wrong.
146
+
147
+ **`charges.create` resolves on 202, not on settlement.** The charge row exists
148
+ immediately; the money arrives seconds to minutes later, cross-chain. The
149
+ outcome comes from `charge.succeeded` / `charge.failed`, or `charges.retrieve`.
150
+
151
+ **`idempotencyKey` is required.** Use a natural key — an invoice id, or
152
+ `${userId}:${period}` — so that a retry after a timeout collects once.
153
+
154
+ ## Errors
155
+
156
+ Every refusal is a class, so `catch` can ask which one it is:
157
+
158
+ | Class | Means |
159
+ |---|---|
160
+ | `RailNotEnabled` | The account has not been granted the rail |
161
+ | `MandateRevoked` / `MandateNotActive` | The payer withdrew it, or it was never signed |
162
+ | `AmountOverCap` | This charge is larger than `maxAmount` |
163
+ | `PeriodCapExceeded` | Fits the cap, but not what is left this period |
164
+ | `IdempotencyKeyReused` | That key was used with a different body |
165
+ | `ChargeConflict` | Two charges raced. Retry with the same key |
166
+
167
+ All extend `SweepError`, which carries `code`, `status`, and — on a server-side
168
+ failure — a `reference` to quote to support.
169
+
170
+ ## Reference
171
+
172
+ ```ts
173
+ sweep.mandates.create(params) // → Mandate
174
+ sweep.mandates.retrieve(id) // → Mandate
175
+ sweep.mandates.list({ status?, limit? }) // → Mandate[]
176
+ sweep.mandates.revoke(id) // → Mandate
177
+
178
+ sweep.charges.create(params, { idempotencyKey }) // → Charge (status "pending")
179
+ sweep.charges.retrieve(id) // → Charge
180
+ sweep.charges.list({ mandate?, status?, limit? }) // → Charge[]
181
+
182
+ sweep.webhooks.verify(rawBody, signature, secret) // throws, or returns void
183
+ sweep.webhooks.construct(rawBody, signature, secret) // → WebhookEvent
184
+ sweep.webhooks.express(secret, handlers) // → express handler
185
+
186
+ usdc("9.00") // → 9000000, branded
187
+ format(9000000) // → "9.00"
188
+ ```
189
+
190
+ Amounts are USDC, 6 decimals. Dates are `Date`. Fields are camelCase; the wire
191
+ is snake_case and the client translates.
@@ -0,0 +1,147 @@
1
+ import type { Charge, CheckoutSession, CreateChargeParams, CreateMandateParams, CreateSessionParams, Mandate, RetrievedSession, Subscription } from "./types.js";
2
+ export interface SweepOptions {
3
+ /** Override for self-hosting or a tunnel. Defaults to the hosted API. */
4
+ baseUrl?: string;
5
+ /** Per-request timeout in ms. Default 30s. */
6
+ timeoutMs?: number;
7
+ /**
8
+ * How many times to retry a request that failed in a way that cannot have
9
+ * been applied — a network error, a 429, a 5xx. Default 2.
10
+ *
11
+ * Charges are exempt from the "cannot have been applied" reasoning, but they
12
+ * carry an Idempotency-Key, which is what makes retrying them safe.
13
+ */
14
+ maxRetries?: number;
15
+ }
16
+ export declare class Sweep {
17
+ readonly mandates: Mandates;
18
+ readonly charges: Charges;
19
+ /** Hosted plans: start a checkout for a plan you made in the portal. */
20
+ readonly checkout: {
21
+ sessions: Sessions;
22
+ };
23
+ /** Hosted plans: read and cancel what checkout created. */
24
+ readonly subscriptions: Subscriptions;
25
+ private readonly apiKey;
26
+ private readonly baseUrl;
27
+ private readonly timeoutMs;
28
+ private readonly maxRetries;
29
+ constructor(apiKey: string, options?: SweepOptions);
30
+ /**
31
+ * A 200 that is not JSON almost always means `baseUrl` is pointing at the web
32
+ * app rather than the API — a single-page app answers every path with its
33
+ * index.html, so the request "succeeds" and then dies in JSON.parse with
34
+ * `Unexpected token '<'`. Say that, rather than letting the parser say it.
35
+ */
36
+ private parse;
37
+ /** @internal */
38
+ request<T>(method: string, path: string, opts?: {
39
+ body?: unknown;
40
+ query?: Record<string, string | number | undefined>;
41
+ idempotencyKey?: string;
42
+ }): Promise<T>;
43
+ }
44
+ declare class Mandates {
45
+ private readonly sweep;
46
+ constructor(sweep: Sweep);
47
+ /**
48
+ * Create a pending mandate and get back the URL to send the payer to.
49
+ *
50
+ * Nothing is authorized yet. The mandate becomes chargeable when the payer
51
+ * signs and `mandate.authorized` fires — start your billing clock on that
52
+ * webhook, not on the redirect, which they can close.
53
+ */
54
+ create(params: CreateMandateParams): Promise<Mandate>;
55
+ retrieve(id: string): Promise<Mandate>;
56
+ list(params?: {
57
+ status?: string;
58
+ limit?: number;
59
+ }): Promise<Mandate[]>;
60
+ /**
61
+ * Stop honouring a mandate. Idempotent, and fires `mandate.revoked`.
62
+ *
63
+ * This is a decision recorded on our side, not a cryptographic one: the
64
+ * grants stay signed on chain, because only the payer's wallet can disable
65
+ * them. We stop redeeming them.
66
+ */
67
+ revoke(id: string): Promise<Mandate>;
68
+ }
69
+ declare class Charges {
70
+ private readonly sweep;
71
+ constructor(sweep: Sweep);
72
+ /**
73
+ * Collect one pull against a mandate.
74
+ *
75
+ * Answers 202, never 200: the charge row exists immediately, the money
76
+ * arrives seconds to minutes later. Learn the outcome from
77
+ * `charge.succeeded` / `charge.failed`, or by polling `retrieve`.
78
+ *
79
+ * `idempotencyKey` is required by this signature rather than by a runtime
80
+ * 400. Use a natural key — an invoice id, or `${userId}:${period}` — so that
81
+ * a retry after a timeout cannot collect twice.
82
+ */
83
+ create(params: CreateChargeParams, opts: {
84
+ idempotencyKey: string;
85
+ }): Promise<Charge>;
86
+ retrieve(id: string): Promise<Charge>;
87
+ list(params?: {
88
+ mandate?: string;
89
+ status?: string;
90
+ limit?: number;
91
+ }): Promise<Charge[]>;
92
+ }
93
+ declare class Sessions {
94
+ private readonly sweep;
95
+ constructor(sweep: Sweep);
96
+ /**
97
+ * Start a hosted checkout and get the URL to send the payer to.
98
+ *
99
+ * This is the server half of a "Subscribe with USDC" button: call it from
100
+ * your own endpoint, where `externalRef` comes from your session rather than
101
+ * from the page, and return `url` for the browser to follow.
102
+ */
103
+ create(params: CreateSessionParams): Promise<CheckoutSession>;
104
+ /**
105
+ * Read a session back. `subscriptionId` is set once checkout completed.
106
+ *
107
+ * Do not treat this as the signal to grant access: a payer who closed the tab
108
+ * leaves a session you will poll forever. Act on subscription.created.
109
+ */
110
+ retrieve(id: string): Promise<RetrievedSession>;
111
+ /** Close an open session early, so its link stops working. */
112
+ expire(id: string): Promise<{
113
+ id: string;
114
+ status: string;
115
+ }>;
116
+ }
117
+ declare class Subscriptions {
118
+ private readonly sweep;
119
+ constructor(sweep: Sweep);
120
+ retrieve(id: string): Promise<Subscription>;
121
+ /**
122
+ * The live subscription for one of YOUR user ids, or null.
123
+ *
124
+ * Only active, trialing and past_due count as live. Use it to answer "is
125
+ * this person a subscriber right now" on a page load, not as a substitute
126
+ * for handling the webhooks — polling this per request is a round trip you
127
+ * do not need when subscription.created already told you.
128
+ */
129
+ status(externalRef: string): Promise<Subscription | null>;
130
+ list(params?: {
131
+ status?: string;
132
+ plan?: string;
133
+ externalRef?: string;
134
+ limit?: number;
135
+ }): Promise<Subscription[]>;
136
+ /**
137
+ * Stop a subscription. No further charges are attempted.
138
+ *
139
+ * It also revokes the renewal delegations behind it, so this is not merely a
140
+ * status flag. No money moves: nothing was ever held, so there is nothing to
141
+ * return — see the note on refunds in the README.
142
+ */
143
+ cancel(id: string, opts?: {
144
+ reason?: string;
145
+ }): Promise<Subscription>;
146
+ }
147
+ export {};