@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/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
|
-
#
|
|
1
|
+
# @sweepconsole/node
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/client.d.ts
ADDED
|
@@ -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 {};
|