@curviate/sdk 0.43.0 → 0.45.0-next.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/CHANGELOG.md +424 -1
- package/README.md +106 -28
- package/dist/index.d.ts +16279 -4766
- package/dist/index.js +3020 -709
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,10 +30,11 @@ const curviate = new Curviate({
|
|
|
30
30
|
// baseUrl: "https://api.curviate.com", // default
|
|
31
31
|
// timeout: 30_000, // per-attempt timeout, ms
|
|
32
32
|
// maxRetries: 3, // GET/HEAD retries with backoff
|
|
33
|
+
// client: "my-agent/1.2.0", // Curviate-Client header; default sdk/<version>
|
|
33
34
|
});
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
An API key belongs to one person in one team and acts with that person's role there. `curviate.teams.getCurrent()` says which team and role. A
|
|
37
|
+
An API key belongs to one person in one team and acts with that person's role there. `curviate.teams.getCurrent()` says which team and role. A key may work in one Team or in all of a person's Teams. A Team key acts only in its own Team: leave `team` unset with one; naming another team is refused with `TEAM_ACCESS_DENIED`. A key that works in all of your Teams, or a dashboard session token, picks one with `team: "tnt_..."` (sent as `X-Curviate-Team`), or per call with `curviate.withTeam("tnt_...")`, which overrides the option. Without one, an All-Teams key's Team-level operation is refused with `TEAM_REQUIRED` (a call on one of your accounts takes that account's Team), and a session token acts in your default Team.
|
|
37
38
|
|
|
38
39
|
---
|
|
39
40
|
|
|
@@ -96,7 +97,21 @@ if (unread_count > 0) {
|
|
|
96
97
|
|
|
97
98
|
## Teams
|
|
98
99
|
|
|
99
|
-
`curviate.teams` manages the team the credential acts in: `getCurrent`, `updateCurrent`, `listMembers`, `updateMember` (role), `removeMember` (`"me"` leaves), `createInvites`, `listInvites`, `resendInvite`, `revokeInvite`, plus `list` for every team you belong to. Member and invite writes are for Owners (`INSUFFICIENT_ROLE` otherwise). `curviate.accounts.setTeamAccess(accountId, false)` keeps an account you connected to yourself.
|
|
100
|
+
`curviate.teams` manages the team the credential acts in: `getCurrent`, `updateCurrent`, `listMembers`, `updateMember` (role), `removeMember` (`"me"` leaves), `createInvites`, `listInvites`, `resendInvite`, `revokeInvite`, plus `list` for every team you belong to. Member and invite writes are for Owners; Managers also rename the team, set its logo and manage Member invites (`INSUFFICIENT_ROLE` otherwise). `curviate.accounts.setTeamAccess(accountId, false)` keeps an account you connected to yourself.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const team = await curviate.teams.create({ name: "Client A" }); // you become its Owner; no seat yet
|
|
104
|
+
// An All-Teams key: next_step.team_header is "X-Curviate-Team: <team_id>", usable at once (pass team_id as the `team` option).
|
|
105
|
+
// Any other credential: next_step.command and next_step.url (CLI setup, dashboard Teams page).
|
|
106
|
+
const { items } = await curviate.teams.list({ include: "counts" }); // tier, trial, accounts, active_motions, seats (Owner rows)
|
|
107
|
+
await curviate.teams.setLogo(await readFile("logo.png")); // a PNG up to 1 MB; Owners and Managers; removeLogo() clears it
|
|
108
|
+
|
|
109
|
+
// Lend an unused seat to another of your Teams, and move it back (once no Account uses it).
|
|
110
|
+
const { seat_id } = await curviate.billing.seats.lend({ team_id: "tnt_456" });
|
|
111
|
+
await curviate.billing.seats.return(seat_id);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`teams.list({ include: "counts" })` adds counts on every row for a dashboard session and an All-Teams key, and only on its own row for a Team key. While a seat is lent, a connect naming it or a cancel of it throws `SEAT_LENT`. `curviate.billing.invoices.list()` pages the Team's invoices (Owners only) and `billing.invoices.pdf(number)` resolves with the PDF bytes. `TIER_REQUIRED` carries `err.details.account_id` when one Account (on a seat lent from another Team) lacks the feature while the Team has it.
|
|
100
115
|
|
|
101
116
|
## Tiers
|
|
102
117
|
|
|
@@ -137,32 +152,44 @@ const { items: skipped } = await curviate.contacts.imports.rows(imp.id, { outcom
|
|
|
137
152
|
|
|
138
153
|
await curviate.contacts.updateMany({ contact_ids: [id], add_tags: ["vip"] });
|
|
139
154
|
const all = await curviate.contacts.export({ format: "json", tag: "vip" });
|
|
155
|
+
|
|
156
|
+
// One filter everywhere: several lists, an opt-in total, then act on exactly what it matches.
|
|
157
|
+
const filter = { list_id: ["cl_1", "cl_2"], list_match: "all" as const, not_list_id: ["cl_3"], has_email: true };
|
|
158
|
+
const { total } = await curviate.contacts.list({ ...filter, include_total: true, limit: 1 });
|
|
159
|
+
await curviate.contactLists.create({ name: "Both, with email", from: filter }); // a static list of what matches now
|
|
160
|
+
await curviate.contacts.updateMany({ filter, add_tags: ["vip"] }); // or deleteMany({ filter }); over 10,000 matches is refused
|
|
140
161
|
```
|
|
141
162
|
|
|
142
|
-
`contacts.enrich` fills profile fields from what Curviate already stores, else queues paced live profile views from an account you name: a live view is a real profile view the person may see. `curviate.contactLists` and `curviate.tags` rename and delete; a Tag has no create call, the first write naming it creates it. Refusals: `NAME_TAKEN`, `RESOURCE_IN_USE` (a live Motion or a running import uses it), `CONTACT_LIMIT_REACHED`, `IMPORT_QUEUE_FULL`. Deletes are for Owners and Managers.
|
|
163
|
+
`list_id` and `not_list_id` take up to 20 Contact list ids (an array or a comma list). `curviate.doNotContact` is the Team-wide do-not-contact list (`list`, `get`, `add`, `update`, `remove`): no Motion contacts a person on it and a write toward them throws `DO_NOT_CONTACT`. `contacts.enrich` fills profile fields from what Curviate already stores, else queues paced live profile views from an account you name: a live view is a real profile view the person may see. `curviate.contactLists` and `curviate.tags` rename and delete; a Tag has no create call, the first write naming it creates it. Refusals: `NAME_TAKEN`, `RESOURCE_IN_USE` (a live Motion or a running import uses it), `CONTACT_LIMIT_REACHED`, `IMPORT_QUEUE_FULL`. Deletes are for Owners and Managers.
|
|
143
164
|
|
|
144
165
|
## Sequences
|
|
145
166
|
|
|
146
|
-
A Motion runs one Sequence: a JSON document of steps (`do`, `wait`, `until`, `if`, `
|
|
167
|
+
A Motion runs one Sequence: a JSON document of steps (`do`, `wait`, `until`, `if`, `tag`, and the legacy `emit`), run from the first step and ended explicitly with `"end"`. The SDK has an offline module for it: a typed builder that emits exactly that document, and `validateSequence`, which gives the verdict the API gives. Nothing in it touches the network, a key or the file system, so it runs in CI, browsers and edge functions. It needs TypeScript 5.0 or later.
|
|
147
168
|
|
|
148
169
|
```ts
|
|
149
170
|
import { Sequence, step } from "@curviate/sdk";
|
|
150
171
|
|
|
151
172
|
const seq = Sequence.create({ on_reply: "end" }).add(
|
|
152
|
-
step.do("invite", "invite.send", { with: { note: null, withdraw_after: "21d" } }),
|
|
173
|
+
step.do("invite", "invite.send", { with: { note: null, withdraw_after: "21d" }, event: "invite_sent" }),
|
|
153
174
|
step.until("accepted", "invite.accepted", { timeout: "21d", then: "opener", else: "end" }),
|
|
154
175
|
step.do("opener", "message.send", {
|
|
155
|
-
|
|
156
|
-
|
|
176
|
+
variants: [
|
|
177
|
+
{ id: "named", text: "Hi {{first_name}}, {{opener}}" },
|
|
178
|
+
{ id: "plain", text: "{{opener}}" },
|
|
179
|
+
],
|
|
180
|
+
with: { fallback: "Hi, thanks for connecting." },
|
|
157
181
|
}),
|
|
182
|
+
step.until("r_opener", "message.replied", { timeout: "4d", then: "end" }),
|
|
183
|
+
step.if("seen", { "message.seen": true }, { then: "bump", else: "end" }),
|
|
184
|
+
step.do("bump", "message.send", { with: { text: "{{followup}}", fallback: "Quick follow-up in case this got buried." }, next: "end" }),
|
|
158
185
|
);
|
|
159
186
|
|
|
160
187
|
const document = seq.toDocument(); // the Sequence document, keys in canonical order
|
|
161
188
|
```
|
|
162
189
|
|
|
163
|
-
Field names are the document's keys (`on_reply`, `note_fallback`, `withdraw_after`). With literal arguments, the type checker refuses what the API would: a typo'd action, event, reaction or predicate, a `{{...}}` that is not a
|
|
190
|
+
Field names are the document's keys (`on_reply`, `note_fallback`, `withdraw_after`). Variables: `{{first_name}}` (any standard Contact field), `{{my_first_name}}`, `{{my_last_name}}`, `{{my_full_name}}` (the sending account), and any other `{{<key>}}` is the custom field `contact.extra.<key>`; the long forms `{{contact.first_name}}`, `{{contact.extra.opener}}` and `{{account.first_name}}` stay valid. A messaging step takes one text in `with` or exactly 2 `variants` (A/B). An optional `event` on a `do` or `tag` step sends a custom event each time the step runs; `until event.<name>` waits for one your agent sends (`enrollments.events`). With literal arguments, the type checker refuses what the API would: a typo'd action, event, reaction or predicate, a `{{...}}` that is not a variable (`{{firstName}}`, `{{my_title}}`, a `| default:` filter), a text with a variable and no fallback, one variant, a duration like `"1w"`, the reserved id `"end"`, and a jump to a step you never added (at `toDocument()`). Text the type checker cannot see (a `string` variable) is checked when you call `validate()` or `toDocument()`.
|
|
164
191
|
|
|
165
|
-
Every builder operation (`add`, `insertBefore`, `replace`, `remove`, `onReply`) returns a new builder. `toDocument()` throws `SEQUENCE_INVALID` with every error in `err.errors
|
|
192
|
+
Every builder operation (`add`, `insertBefore`, `replace`, `remove`, `onReply`) returns a new builder. `toDocument()` throws `SEQUENCE_INVALID` with every error in `err.errors`, the same list the API returns: `path`, `rule`, and the compiler-style `code` (`R1` to `R28`), `pointer` (a JSON Pointer into the document), `hint` (the fix), `docs_url`, `severity` and, for a did-you-mean, `candidates`. Warnings (`W1` to `W16`) never block, read them with `validate()`. `JSON.stringify(seq)` equals `JSON.stringify(seq.toDocument())`. `newStepId(document, kind)` names a new step as the dashboard editor does (`message`, `message_2`, `w_<step>` for a wait before a step).
|
|
166
193
|
|
|
167
194
|
Raw JSON stays first class. Validate a document you wrote by hand, or load one (a Motion's `sequence`) to edit it:
|
|
168
195
|
|
|
@@ -170,14 +197,14 @@ Raw JSON stays first class. Validate a document you wrote by hand, or load one (
|
|
|
170
197
|
import { Sequence, step, validateSequence } from "@curviate/sdk";
|
|
171
198
|
|
|
172
199
|
const { errors, warnings } = validateSequence(JSON.parse(text));
|
|
173
|
-
for (const e of errors) console.error(`${e.path}: ${e.rule}`);
|
|
200
|
+
for (const e of errors) console.error(`${e.path}: ${e.rule} (${e.code}: ${e.hint})`);
|
|
174
201
|
|
|
175
202
|
const edited = Sequence.fromDocument(motion.sequence) // throws SEQUENCE_INVALID when invalid
|
|
176
203
|
.insertBefore("opener", step.wait("settle", "1d"))
|
|
177
204
|
.toDocument();
|
|
178
205
|
```
|
|
179
206
|
|
|
180
|
-
`{{motion.<key>}}`
|
|
207
|
+
Deprecated forms are accepted with warning `W13` until 2027-01-19, then refused: 3 to 5 variants (use 2), `until signal.<name>` (use `until event.<name>`) and `{{motion.<key>}}` (write the text in place or use a custom field; offline it also gives `W9`). Warnings that need the Motion's sending accounts or settings come from the API only. `SEQUENCE_SCHEMA_V1` is the published JSON Schema (`SEQUENCE_SCHEMA_URL`) for editors and tooling. R29 (an `until event.<name>` gate on a field the incoming event's sample does not carry) needs your Team's incoming events, so it comes from the API only. The validator is the API's own rule code, shipped with this SDK: a document using an action or event added to the API later is refused offline while the API accepts it; upgrade the SDK.
|
|
181
208
|
|
|
182
209
|
## Motions and Enrollments
|
|
183
210
|
|
|
@@ -190,21 +217,29 @@ const motion = await curviate.motions.create({
|
|
|
190
217
|
name: "Q4 webinar follow-up",
|
|
191
218
|
contact_list_ids: ["cl_123"],
|
|
192
219
|
account_ids: ["acc_123"],
|
|
193
|
-
schedule: {
|
|
220
|
+
schedule: { days: ["mon", "tue", "wed", "thu", "fri"] }, // read in each sending account's own time zone
|
|
194
221
|
sequence: seq, // a builder: converted with toDocument(); an invalid one throws SEQUENCE_INVALID, nothing is sent
|
|
195
222
|
});
|
|
196
223
|
|
|
197
224
|
const { paths, samples, projection } = await curviate.motions.preview(motion.id); // a read: nothing is sent to LinkedIn
|
|
198
225
|
await curviate.motions.start(motion.id);
|
|
199
226
|
|
|
200
|
-
// Edit later: load the stored document into a builder.
|
|
227
|
+
// Edit later: load the stored document into a builder. if_sequence_revision refuses the save
|
|
228
|
+
// with SEQUENCE_CONFLICT (the stored document in err.current) when someone saved since.
|
|
201
229
|
const current = await curviate.motions.get(motion.id);
|
|
202
230
|
await curviate.motions.update(motion.id, {
|
|
203
231
|
sequence: Sequence.fromDocument(current.sequence).insertBefore("opener", step.wait("settle", "1d")),
|
|
232
|
+
if_sequence_revision: current.sequence_revision,
|
|
204
233
|
});
|
|
234
|
+
|
|
235
|
+
// Enrollments: one Contact's run on one account.
|
|
236
|
+
const { items: failed } = await curviate.enrollments.list({ motion_id: motion.id, done_step: "opener", outcome: "failed" });
|
|
237
|
+
await curviate.enrollments.bulk({ action: "retry", ids: failed.map((e) => e.id) }); // one result per Enrollment
|
|
238
|
+
await curviate.enrollments.retry("enr_123"); // one: throws NOTHING_TO_RETRY unless its Step failed
|
|
239
|
+
await curviate.enrollments.events("enr_123", { name: "research_ready" }); // moves an `until event.research_ready`
|
|
205
240
|
```
|
|
206
241
|
|
|
207
|
-
A plain object goes as is and the API validates it; wrap hand-written JSON in `Sequence.fromDocument(json)` to have it checked offline first. `sequence: null` clears a draft's. Status operations: `start`, `pause`, `resume`, `archive` (irreversible), `delete` (drafts only); `addContacts` / `removeContacts` enroll or take out Contacts directly; `getSequenceVersion` reads a past version. `curviate.enrollments` lists one Contact's run per account (`list`, `get` with its Step history) and acts on it: `
|
|
242
|
+
A plain object goes as is and the API validates it; wrap hand-written JSON in `Sequence.fromDocument(json)` to have it checked offline first. `sequence: null` clears a draft's. Status operations: `start`, `pause`, `resume`, `archive` (irreversible), `delete` (drafts only); `addContacts` / `removeContacts` enroll or take out Contacts directly; `getSequenceVersion` reads a past version. `curviate.enrollments` lists one Contact's run per account (`list`, filtered by status, Step, Sequence version, or how a Step ended: `done_step` with `outcome`, `reason`, `variant_id`, `relationship_at_enrollment`; `get` with its Step history) and acts on it: `pause`, `resume()` (lift your pause), `retry(id)` (retry the failed Step; it acts only on a failure, else throws `NOTHING_TO_RETRY`, so a repeated call never sends a delivered message again), `resume({ at })` (an explicit jump to a Step, not guarded: it re-enters a Step that already sent; use `retry` for a failure), `events({ name })`, `reassign({ account_id })`, `stop`, and `bulk` for up to 1,000 at once by ids or filter. `signal` is the deprecated name of `events`, accepted until 2027-01-19. Refusals: `MOTION_NOT_STARTABLE` (every gap in `err.errors`), `MOTION_STATUS_CONFLICT`, `ENROLLMENT_NOT_ACTIVE`, `SEQUENCE_INVALID`, `SEQUENCE_CONFLICT`, `STEP_IN_FLIGHT` (retry in a minute), `NOTHING_TO_RETRY` (from `retry`, or per Enrollment in `bulk`), `ACCOUNT_ALREADY_ENGAGED`, `ALREADY_ENROLLED_ON_ACCOUNT`, `KNOWN_CHAT`.
|
|
208
243
|
|
|
209
244
|
## Network, Unibox and Analytics
|
|
210
245
|
|
|
@@ -216,9 +251,12 @@ const { items, coverage } = await curviate.network.connections.list({ account_id
|
|
|
216
251
|
for await (const c of curviate.network.connections.list({ q: "founder" })) console.log(c.public_profile_url);
|
|
217
252
|
const { items: sync } = await curviate.network.syncStatus();
|
|
218
253
|
await curviate.network.resync({ account_id: "acc_1" }); // once per account per 24 h
|
|
254
|
+
await curviate.network.resync(); // Resync all: network_resync_list, one item per account you can operate
|
|
255
|
+
for await (const p of curviate.network.connections.list({ group: "person" })) console.log(p.object); // connection_person: one per person
|
|
219
256
|
|
|
220
257
|
// One inbox over every account you can operate.
|
|
221
258
|
for await (const chat of curviate.unibox.list({ unread: true })) console.log(chat.chat_id);
|
|
259
|
+
const { items: theirs, total } = await curviate.unibox.list({ person: "con_1", motion_ids: ["none"], include_total: true }); // total: every match, not the page
|
|
222
260
|
const { unread_chats } = await curviate.unibox.unreadCount();
|
|
223
261
|
await curviate.unibox.accounts.update("acc_1", { sync_full_inbox: true }); // by the person who connected it
|
|
224
262
|
await curviate.unibox.refresh();
|
|
@@ -227,11 +265,26 @@ await curviate.unibox.enroll("acc_1", "chat_1", { motion_id: "mot_1" });
|
|
|
227
265
|
|
|
228
266
|
// Motion results, activity in budget units, the events feed.
|
|
229
267
|
const overview = await curviate.analytics.motions.list({ from: "2026-10-01", to: "2026-10-31" });
|
|
268
|
+
const summary = await curviate.analytics.motions.list({ range: "30d", compare: "previous_period", origin: ["motion", "unibox"] });
|
|
230
269
|
const detail = await curviate.analytics.motions.get("mot_1");
|
|
231
270
|
const { types } = await curviate.analytics.activity(); // per BudgetRow; never add rows together
|
|
232
271
|
for await (const event of curviate.analytics.events.list({ kind: ["message.replied"] })) console.log(event.motion_name);
|
|
272
|
+
const { items: forOne } = await curviate.analytics.events.list({ contact_id: "con_1", origin: ["unibox", "api"] }); // 404 for an unknown, other-team or merged-away id
|
|
273
|
+
|
|
274
|
+
// Files: stream the Analytics tables now, or run a background export and download it within 24 hours.
|
|
275
|
+
const csv = await curviate.analytics.export({ table: ["daily"], range: "90d" }); // ArrayBuffer
|
|
276
|
+
const job = await curviate.exports.create({ entity: "analytics", format: "csv", tables: ["daily", "motions"], filters: { range: "12m" } });
|
|
277
|
+
const file = await curviate.exports.download(job.id); // EXPORT_NOT_READY until exports.get(job.id).status is "ready"
|
|
278
|
+
|
|
279
|
+
// One account at a glance, and its own daily numbers.
|
|
280
|
+
const { performance, flags } = await curviate.accounts.overview("acc_1", { range: "30d" });
|
|
281
|
+
const { items: metrics } = await curviate.accounts.metrics("acc_1", { from: "2026-08-01" });
|
|
233
282
|
```
|
|
234
283
|
|
|
284
|
+
Every rate is the outcome so far over what was sent in the window, not over old enough cohorts, so it keeps moving as replies arrive: read it with `maturing` (sent under 7 days ago) and `low_sample` (under 30). Messages and InMails are counted apart (`reply` and `inmail_reply`). Enrollment counts are by state right now (`queued`, `active`, `paused`, `finished`, `failed`, `skipped`); the window does not change them. One export runs per Team at a time (`EXPORT_IN_PROGRESS`, with `err.details.export_id`).
|
|
285
|
+
|
|
286
|
+
Unibox traps. A send through the API (`messaging.sendMessage`, `startChat`, `sendInMail`) marks the chat read, so `unread` turns `false` and `marked_unread` clears; a Motion send leaves it. `marked_unread` is `{origin: "unibox" | "api" | "linkedin", at}` when a chat was made unread without a new message from them. A disconnected account stays in `unibox.accounts.list()` and `unibox.list()` (`account_status: "disconnected"`), read-only: its messages come from the stored copy (`coverage.complete`, attachments `unavailable`), and a write on it throws `REAUTH_REQUIRED` (409). `unibox.refresh` runs once per account per 2 minutes (`cooldown` with `next_allowed_at` after that) and covers accounts with Motion chats or Motion outreach even without full-inbox sync; `not_synced` means none of those apply. A sent message carries `origin` (`motion`, `unibox`, `api`, `linkedin`; omitted when unknown, never on a received message or `last_message`) and `is_deleted`. Analytics `origin` is `motion`, `unibox` or `api`; `direct` is an alias for `unibox` and `api`.
|
|
287
|
+
|
|
235
288
|
Needs Automate: `network.resync`, `unibox.refresh`, `unibox.tagContact`, `unibox.enroll` and turning full-inbox sync on (off passes on any Tier). Messages and actions on a chat stay on `curviate.account(id).messaging`.
|
|
236
289
|
|
|
237
290
|
## Drafts and scheduled posts
|
|
@@ -320,9 +373,12 @@ try {
|
|
|
320
373
|
// nothing reached LinkedIn and nothing was spent, so backing off is the
|
|
321
374
|
// wrong move. Wait until err.resetAt, or raise the setting the hint
|
|
322
375
|
// names on PATCH /v1/{account_id}/safety-policy. The ceiling comes from the
|
|
323
|
-
// account's safety policy or the
|
|
324
|
-
//
|
|
325
|
-
//
|
|
376
|
+
// account's safety policy or the Team default (GET/PATCH /v1/safety-policy).
|
|
377
|
+
// Limits are always enforced. err.safetyReason "activity_window": a live
|
|
378
|
+
// write outside the account's active hours (the hint names active_hours).
|
|
379
|
+
// resetAt is null in the three cases no clock frees: the pending_invites
|
|
380
|
+
// backlog, an InMail credit pool LinkedIn regrants on its own schedule,
|
|
381
|
+
// and an account whose time zone is not known yet.
|
|
326
382
|
console.warn(`${err.budgetRow} is spent until ${err.resetAt ?? "no fixed time"}`);
|
|
327
383
|
console.warn(`change ${err.safetyHint?.parameter} to lift it`);
|
|
328
384
|
break;
|
|
@@ -563,21 +619,43 @@ Each event also carries its delivery metadata: `event.id`, `event.webhook_id`, a
|
|
|
563
619
|
|
|
564
620
|
### De-duplicating retries
|
|
565
621
|
|
|
566
|
-
Delivery is at-least-once, so your handler can see the same
|
|
567
|
-
|
|
568
|
-
`event.
|
|
569
|
-
|
|
622
|
+
Delivery is at-least-once, so your handler can see the same event more than once. Key on
|
|
623
|
+
`event.id`: it is the delivery id (`wdl_`), the same on every attempt and every redelivery
|
|
624
|
+
(before this release it changed per attempt). `event.delivered_at` is when the body was
|
|
625
|
+
rendered, also the same on every attempt.
|
|
570
626
|
|
|
571
627
|
```ts
|
|
572
|
-
|
|
573
|
-
|
|
628
|
+
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
`curviate.webhooks.deliveries.get(event.webhook_id, event.id)` reads that delivery's status and
|
|
632
|
+
attempts; `deliveries.list(id, { failed: true })` lists what did not get through, and
|
|
633
|
+
`webhooks.redeliver(id, deliveryId)` sends one again, byte for byte, within 24 hours.
|
|
634
|
+
|
|
635
|
+
### Rotating the secret
|
|
636
|
+
|
|
637
|
+
`webhooks.rotateSecret(id)` returns a new secret once. For 24 hours every delivery is signed
|
|
638
|
+
with both (`Curviate-Signature: t=...,v1=<new>,v1=<old>`) and `constructEvent` accepts the
|
|
639
|
+
delivery when any `v1` matches the secret you pass, so the old and the new secret both verify
|
|
640
|
+
while you deploy. Older SDK versions check only the last `v1`: upgrade before you rotate.
|
|
641
|
+
|
|
642
|
+
### Body templates
|
|
643
|
+
|
|
644
|
+
A webhook's `body_template` replaces the standard payload with your own JSON (`{{variables}}`
|
|
645
|
+
inside its strings; `webhooks.variables({ source })` lists them). Check it offline with the
|
|
646
|
+
API's own lint, and preview it:
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
import { renderWebhookTemplate, validateWebhookTemplate } from "@curviate/sdk";
|
|
650
|
+
|
|
651
|
+
const { errors } = validateWebhookTemplate(template, { source: "motion" });
|
|
652
|
+
for (const e of errors) console.error(`${e.line}:${e.col} ${e.code} ${e.rule}`);
|
|
653
|
+
const body = renderWebhookTemplate(template, { event: "enrollment.finished", contact: { email: "alex@example.com" } });
|
|
574
654
|
```
|
|
575
655
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
`event.id` is how you match one attempt to one line in your delivery logs, keyed
|
|
580
|
-
alongside the stable `occurred_at` composite above.
|
|
656
|
+
A webhook's `request_url` reads back masked (`https://host/•••`); sending that value back on
|
|
657
|
+
`update` keeps the stored URL. A `headers` entry with a key and no value keeps that header's
|
|
658
|
+
stored value.
|
|
581
659
|
|
|
582
660
|
`WebhookSignatureError` is NOT a `CurviateError`. Narrow with `instanceof WebhookSignatureError`.
|
|
583
661
|
Its `reason` tells you where to look: `malformed_header` means the signature header could not be
|