@certysign/sdk 2.5.0 → 2.5.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/README.md CHANGED
@@ -23,7 +23,11 @@ Official Node.js SDK for **CertySign Trust Services** — hash-based digital sig
23
23
  - [client.certificates — X.509 Certificate Management](#clientcertificates--x509-certificate-management)
24
24
  - [client.pki — PKI Infrastructure](#clientpki--pki-infrastructure)
25
25
  - [client.envelopes — Envelope Management](#clientenvelopes--envelope-management)
26
- - [client.legacySign — Legacy Document Signing](#clientlegacysign--legacy-document-signing)
26
+ - [client.legacySign — Legacy Document Signing](#clientlegacysign--legacy-document-signing)
27
+ - [client.webhooks — Event Notifications](#clientwebhooks--event-notifications)
28
+ - [client.billing — Wallet, Quotes and Pricing](#clientbilling--wallet-quotes-and-pricing)
29
+ - [client.signers — Sponsored Signer Certificates](#clientsigners--sponsored-signer-certificates)
30
+ - [client.request — Any Endpoint](#clientrequest--any-endpoint)
27
31
  - [Complete Examples](#complete-examples)
28
32
  - [Error Handling](#error-handling)
29
33
  - [Rate Limiting](#rate-limiting)
@@ -1117,6 +1121,272 @@ Legacy methods: `quickSign()`, `batchSign()`, `verifyById()`, `verifyDocument()`
1117
1121
 
1118
1122
  ---
1119
1123
 
1124
+ ### `client.webhooks` — Event Notifications
1125
+
1126
+ Register an endpoint and CertySign calls you when something finishes, instead of you
1127
+ asking repeatedly. Requires the `webhook:manage` permission on your API key.
1128
+ **Deliveries are not charged**, and neither are the status endpoints.
1129
+
1130
+ #### `create({ url, events, description? })` — Register an endpoint
1131
+
1132
+ ```js
1133
+ const { data } = await client.webhooks.create({
1134
+ url: 'https://api.example.com/hooks/certysign',
1135
+ events: ['envelope.completed', 'envelope.declined'], // omit for all events
1136
+ description: 'Production order pipeline',
1137
+ });
1138
+
1139
+ // data.secret is returned ONCE and is never readable again. Store it now.
1140
+ process.env.CERTYSIGN_WEBHOOK_SECRET = data.secret;
1141
+ ```
1142
+
1143
+ Events: `envelope.sent`, `envelope.viewed`, `envelope.signed` (per signature),
1144
+ `envelope.completed`, `envelope.declined`, `envelope.voided`, `envelope.expired`,
1145
+ `signing_session.completed`, `sponsorship.created`,
1146
+ `sponsorship.certificate_issued`, `sponsorship.verification_rejected`,
1147
+ `sponsorship.failed`, `webhook.test`.
1148
+
1149
+ #### `list()`, `remove(webhookId)`, `test(webhookId)`
1150
+
1151
+ ```js
1152
+ const { data } = await client.webhooks.list();
1153
+ await client.webhooks.test(data.webhooks[0].webhookId); // sends a webhook.test delivery
1154
+ await client.webhooks.remove(data.webhooks[0].webhookId);
1155
+ ```
1156
+
1157
+ #### `deliveries({ webhookId?, status?, limit? })`, `redeliver(deliveryId)`
1158
+
1159
+ ```js
1160
+ // What did we send, and what did your endpoint say?
1161
+ const { data } = await client.webhooks.deliveries({ status: 'failed', limit: 50 });
1162
+ for (const d of data.deliveries) {
1163
+ console.log(d.event, d.responseStatus, d.error);
1164
+ await client.webhooks.redeliver(d.deliveryId);
1165
+ }
1166
+ ```
1167
+
1168
+ #### Verifying the signature
1169
+
1170
+ Use the built-in helper. It is timing-safe, enforces a freshness window, and reports
1171
+ the two mistakes that otherwise fail silently — signing the body alone (which leaves a
1172
+ captured delivery replayable forever) and verifying after `JSON.parse` (which changes
1173
+ the bytes the signature covers).
1174
+
1175
+ ```js
1176
+ const { CertySignClient } = require('@certysign/sdk');
1177
+
1178
+ // express.raw, NOT express.json — the signature covers the bytes as sent.
1179
+ app.post('/hooks/certysign', express.raw({ type: 'application/json' }), (req, res) => {
1180
+ const { valid, reason, event } = CertySignClient.verifyWebhookSignature({
1181
+ secret: process.env.CERTYSIGN_WEBHOOK_SECRET,
1182
+ headers: req.headers,
1183
+ rawBody: req.body, // Buffer or string, never a parsed object
1184
+ toleranceSeconds: 300, // default
1185
+ });
1186
+
1187
+ if (!valid) return res.status(401).send(reason);
1188
+
1189
+ if (alreadyHandled(event.eventId)) return res.sendStatus(200); // at-least-once
1190
+ handle(event);
1191
+ res.sendStatus(200); // answer fast; do the work afterwards
1192
+ });
1193
+ ```
1194
+
1195
+ If you would rather implement it yourself: the signature is HMAC-SHA256 over
1196
+ `"<timestamp>.<raw body>"` — **not the body alone** — compared timing-safely, over the
1197
+ raw bytes, before any JSON parsing.
1198
+
1199
+ ```js
1200
+ const crypto = require('crypto');
1201
+
1202
+ // express.raw, NOT express.json — the signature covers the bytes as sent.
1203
+ app.post('/hooks/certysign', express.raw({ type: 'application/json' }), (req, res) => {
1204
+ const ts = req.get('X-CertySign-Timestamp');
1205
+ const sig = (req.get('X-CertySign-Signature') || '').replace('sha256=', '');
1206
+
1207
+ // Reject anything older than 5 minutes.
1208
+ if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
1209
+
1210
+ const expected = crypto
1211
+ .createHmac('sha256', process.env.CERTYSIGN_WEBHOOK_SECRET)
1212
+ .update(ts + '.' + req.body.toString())
1213
+ .digest('hex');
1214
+
1215
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
1216
+ return res.sendStatus(401);
1217
+ }
1218
+
1219
+ const event = JSON.parse(req.body.toString());
1220
+ if (alreadyHandled(event.eventId)) return res.sendStatus(200); // at-least-once
1221
+
1222
+ handle(event);
1223
+ res.sendStatus(200); // answer fast; do the work afterwards
1224
+ });
1225
+ ```
1226
+
1227
+ #### Delivery, retries and duplicates
1228
+
1229
+ - Respond **2xx** quickly. Anything else counts as a failure.
1230
+ - Retries on network errors, timeouts and 5xx: 10s, 1m, 5m, 30m, 2h, then stop.
1231
+ - A **4xx other than 429 is not retried** — your endpoint rejected the shape, and the
1232
+ same bytes would be rejected again.
1233
+ - Delivery is **at-least-once**. De-duplicate on `eventId`, which is stable across
1234
+ retries and redeliveries.
1235
+ - After 20 consecutive failures the webhook is disabled and the reason recorded.
1236
+
1237
+ **Webhooks do not replace polling.** A delivery you never received is unrecoverable
1238
+ unless you can ask for the current state, so the status endpoints remain — and they
1239
+ are free to call.
1240
+
1241
+ ---
1242
+
1243
+ ### `client.billing` — Wallet, Quotes and Pricing
1244
+
1245
+ Read-only, and every call here is free — checking a balance must never itself cost
1246
+ money.
1247
+
1248
+ #### `wallet()` — Token balance and standing
1249
+
1250
+ ```js
1251
+ const { data } = await client.billing.wallet();
1252
+ console.log(`${data.availableBalance} ${data.currency} available`);
1253
+ ```
1254
+
1255
+ Spend against **`availableBalance`**, not `balance`: `balance` includes tokens already
1256
+ reserved by operations in flight. A `status` of `'suspended'` means chargeable calls
1257
+ are refused even when the balance looks healthy, so check it before concluding you can
1258
+ afford something.
1259
+
1260
+ #### `quote({ operationType, operationSubType?, quantity?, additionalSigners? })`
1261
+
1262
+ What an operation will cost, and whether you can currently cover it. Call it **before**
1263
+ a large batch: one answer up front beats `INSUFFICIENT_BALANCE` halfway through, with
1264
+ some documents signed and charged and the rest not.
1265
+
1266
+ ```js
1267
+ const { data } = await client.billing.quote({
1268
+ operationType: 'document_signing', // or 'certificate_issuance'
1269
+ operationSubType: 'ADVANCED', // e.g. 'ADVANCED', 'ADES'
1270
+ quantity: documents.length,
1271
+ });
1272
+
1273
+ if (!data.canAfford) {
1274
+ throw new Error(`Need ${data.tokensTotal}, have ${data.availableBalance}`);
1275
+ }
1276
+ ```
1277
+
1278
+ #### `transactions({ limit?, skip?, type?, status? })`, `plan()`, `pricing()`
1279
+
1280
+ ```js
1281
+ const { data } = await client.billing.transactions({ limit: 100, type: 'debit' });
1282
+ const { data: plan } = await client.billing.plan(); // your tier and its limits
1283
+ const { data: rates } = await client.billing.pricing(); // the full price list
1284
+ ```
1285
+
1286
+ ---
1287
+
1288
+ ### `client.signers` — Sponsored Signer Certificates
1289
+
1290
+ A person cannot sign until they hold a certificate. These let you pay for one on their
1291
+ behalf and watch it become usable, instead of discovering at signing time that it is
1292
+ not there.
1293
+
1294
+ #### `signingStatus(email, { issueCompletionUrl? })` — Can this person sign yet?
1295
+
1296
+ Requires `cert:status`. Free to call, so it is safe in front of every send.
1297
+
1298
+ ```js
1299
+ const { data } = await client.signers.signingStatus('jane@example.com');
1300
+
1301
+ if (!data.canSign) {
1302
+ // data.reason is NO_SIGNING_CERTIFICATE or AWAITING_IDENTITY_VERIFICATION
1303
+ console.log(data.reason);
1304
+ }
1305
+ ```
1306
+
1307
+ Worth calling **before** a batch: it turns a per-document failure — discovered halfway
1308
+ through, after the wallet has been charged — into one answer up front.
1309
+
1310
+ For someone stuck at `AWAITING_IDENTITY_VERIFICATION`, ask for a link you can redirect
1311
+ them to, so they need not hunt for the invitation email:
1312
+
1313
+ ```js
1314
+ const { data } = await client.signers.signingStatus(email, { issueCompletionUrl: true });
1315
+ if (data.pendingSponsorship?.completionUrl) {
1316
+ return redirect(data.pendingSponsorship.completionUrl);
1317
+ }
1318
+ ```
1319
+
1320
+ `issueCompletionUrl` **mints a new link and the previous one stops working**, including
1321
+ the one already in their inbox. Ask for it when you are about to redirect someone, not
1322
+ on every poll. It needs `signer:sponsor`, because minting an onboarding credential is
1323
+ the same class of act as sponsoring one.
1324
+
1325
+ #### `sponsor({ email, name?, tier?, restrictToSponsor? })` — Pay for their certificate
1326
+
1327
+ Requires `signer:sponsor`, a paid plan and a funded wallet.
1328
+
1329
+ ```js
1330
+ const { data } = await client.signers.sponsor({
1331
+ email: 'jane@example.com',
1332
+ name: 'Jane Mwangi', // shown on the invitation; the certificate carries the
1333
+ // name on their verified ID
1334
+ tier: 'ades', // 'ses' | 'ades' (default) | 'qes'
1335
+ restrictToSponsor: true, // only signs your documents (default)
1336
+ });
1337
+
1338
+ return redirect(data.completionUrl);
1339
+ ```
1340
+
1341
+ They **cannot sign when this returns** — `status` is `awaiting_member` until they
1342
+ complete identity verification. Subscribe to `sponsorship.certificate_issued` rather
1343
+ than polling for it.
1344
+
1345
+ This also makes them a **member of your organisation** once the certificate issues:
1346
+ they get an account, appear in your member list, and their certificate falls under your
1347
+ governance.
1348
+
1349
+ `restrictToSponsor: false` gives them a certificate usable anywhere, including with
1350
+ your competitors. That is a deliberate choice, not a default.
1351
+
1352
+ #### `resendSetup(email)` — Send the setup link again
1353
+
1354
+ ```js
1355
+ await client.signers.resendSetup('jane@example.com');
1356
+ ```
1357
+
1358
+ **This rotates the token**, which invalidates the link in any email already sent. That
1359
+ is the point — an old link that still works is an old link someone else can use — but
1360
+ it does mean the earlier email stops working the moment you call this.
1361
+
1362
+ ---
1363
+
1364
+ ### `client.request` — Any Endpoint
1365
+
1366
+ The API can gain an endpoint at any time. This calls one that has no typed method yet,
1367
+ so you are never blocked waiting for an SDK release.
1368
+
1369
+ ```js
1370
+ const res = await client.request('POST', '/sdk/v1/some-new-endpoint', {
1371
+ data: { foo: 'bar' },
1372
+ params: { verbose: true }, // query string
1373
+ idempotencyKey: 'batch-2026-10-01',
1374
+ });
1375
+ ```
1376
+
1377
+ Authentication, retries, idempotency-conflict handling, the error shape and the
1378
+ User-Agent all behave exactly as they do for a typed resource. The only thing you give
1379
+ up is the named method and its documentation.
1380
+
1381
+ Prefer a typed resource where one exists — it records the shape and keeps working if a
1382
+ path changes. Use this when there is no resource yet.
1383
+
1384
+ > New response fields need nothing at all: responses are passed through untouched, so
1385
+ > a field added server-side is readable immediately on whatever version you have
1386
+ > installed.
1387
+
1388
+ ---
1389
+
1120
1390
  ## Complete Examples
1121
1391
 
1122
1392
  ### PDF: PAdES Sign with signCallback (Recommended)
@@ -1387,6 +1657,14 @@ For batch workloads, use `batchHashAndSign()` or `batchSignHashes()` to sign up
1387
1657
 
1388
1658
  ## Migration from v1
1389
1659
 
1660
+ ### What's New in v2.5.1
1661
+
1662
+ - **`client.webhooks`** — register an endpoint and be told when envelopes and
1663
+ sponsorships change state, instead of polling. See
1664
+ [client.webhooks](#clientwebhooks--event-notifications).
1665
+ - The `User-Agent` now reports the real package version. It had been pinned at
1666
+ `1.0.0` for the whole 2.x line, so server logs could not tell SDK versions apart.
1667
+
1390
1668
  ### What's New in v2.2.0
1391
1669
 
1392
1670
  | Feature | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@certysign/sdk",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Official Node.js SDK for CertySign — hash-based digital signing, X.509 certificates, and PKI services. Documents never leave your system.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -11,7 +11,8 @@
11
11
  "scripts": {
12
12
  "test": "jest --coverage",
13
13
  "lint": "eslint src/**/*.js",
14
- "build:types": "npx tsc --emitDeclarationOnly"
14
+ "build:types": "npx tsc --emitDeclarationOnly",
15
+ "test:durability": "node test/durability.test.js"
15
16
  },
16
17
  "keywords": [
17
18
  "certysign",
package/src/index.js CHANGED
@@ -28,6 +28,7 @@ const { DashboardResource } = require('./lib/DashboardResource');
28
28
  const { IdentityResource } = require('./lib/IdentityResource');
29
29
  const { BillingResource } = require('./lib/BillingResource');
30
30
  const { SignerResource } = require('./lib/SignerResource');
31
+ const { WebhookResource } = require('./lib/WebhookResource');
31
32
  const { DocumentHasher } = require('./lib/DocumentHasher');
32
33
  const { SignatureEmbedder } = require('./lib/SignatureEmbedder');
33
34
 
@@ -124,6 +125,7 @@ class CertySignClient {
124
125
  this.identity = new IdentityResource(this._http); // National-ID verification (IPRS)
125
126
  this.billing = new BillingResource(this._http); // Balance, quotes, plan (read-only)
126
127
  this.signers = new SignerResource(this._http); // Sponsor + check the people you sign for
128
+ this.webhooks = new WebhookResource(this._http); // Be told, instead of polling
127
129
  this.hasher = new DocumentHasher(); // Local document hashing
128
130
 
129
131
  // TSA URL: explicit > environment-derived
@@ -194,6 +196,147 @@ class CertySignClient {
194
196
  return { ok: false, environment: this.environment, error: err.message, code: err.code };
195
197
  }
196
198
  }
199
+
200
+ /**
201
+ * Call any CertySign endpoint, including ones this SDK version has no method for.
202
+ *
203
+ * This exists so the backend can ship an endpoint and you can use it the same
204
+ * day, without waiting for an SDK release. Everything a typed resource gives you
205
+ * still applies: authentication, retries, idempotency-conflict handling, the
206
+ * error shape and the User-Agent. The only thing you give up is the named
207
+ * method and its JSDoc.
208
+ *
209
+ * Prefer a typed resource where one exists — it documents the shape and will keep
210
+ * working if a path changes. Reach for this when there is no resource yet.
211
+ *
212
+ * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
213
+ * @param {string} path e.g. '/sdk/v1/some-new-endpoint'
214
+ * @param {object} [options]
215
+ * @param {object} [options.data] JSON body
216
+ * @param {object} [options.params] query string
217
+ * @param {object} [options.headers] extra headers
218
+ * @param {string} [options.idempotencyKey]
219
+ * @returns {Promise<any>} the parsed response body
220
+ *
221
+ * @example
222
+ * // An endpoint added to the API after this SDK was published:
223
+ * const res = await client.request('POST', '/sdk/v1/envelopes/bulk-void', {
224
+ * data: { envelopeIds: ids, reason: 'duplicate batch' },
225
+ * });
226
+ */
227
+ request(method, path, options = {}) {
228
+ if (!method) throw new Error('client.request: method is required (GET, POST, PUT, PATCH, DELETE)');
229
+ if (!path) throw new Error('client.request: path is required (e.g. "/sdk/v1/...")');
230
+ if (!String(path).startsWith('/')) {
231
+ throw new Error(`client.request: path must start with "/" — got "${path}"`);
232
+ }
233
+ return this._http.request(String(method).toUpperCase(), path, options);
234
+ }
235
+
236
+ /**
237
+ * Verify an incoming webhook delivery.
238
+ *
239
+ * Every integrator otherwise hand-writes this, and the two ways to get it wrong
240
+ * both fail silently rather than loudly:
241
+ *
242
+ * - signing the body alone instead of `timestamp + "." + body`, which leaves a
243
+ * captured delivery replayable forever; and
244
+ * - verifying after `JSON.parse`, because re-serialising changes the bytes the
245
+ * signature was computed over.
246
+ *
247
+ * So pass the RAW body: a Buffer or the original string, never a parsed object.
248
+ * With Express that means `express.raw({ type: 'application/json' })`, not
249
+ * `express.json()`.
250
+ *
251
+ * Comparison is timing-safe, and a delivery older than `toleranceSeconds` is
252
+ * rejected even when the signature is good.
253
+ *
254
+ * @param {object} opts
255
+ * @param {string} opts.secret the whsec_... shown once at creation
256
+ * @param {object} opts.headers the request headers (case-insensitive)
257
+ * @param {Buffer|string} opts.rawBody the body exactly as received
258
+ * @param {number} [opts.toleranceSeconds=300] reject deliveries older than this
259
+ * @returns {{ valid: boolean, reason?: string, event?: object }}
260
+ * `event` is the parsed payload, present only when valid is true.
261
+ *
262
+ * @example
263
+ * app.post('/hooks', express.raw({ type: 'application/json' }), (req, res) => {
264
+ * const { valid, reason, event } = CertySignClient.verifyWebhookSignature({
265
+ * secret: process.env.CERTYSIGN_WEBHOOK_SECRET,
266
+ * headers: req.headers,
267
+ * rawBody: req.body,
268
+ * });
269
+ * if (!valid) return res.status(401).send(reason);
270
+ *
271
+ * if (alreadyHandled(event.eventId)) return res.sendStatus(200); // at-least-once
272
+ * handle(event);
273
+ * res.sendStatus(200);
274
+ * });
275
+ */
276
+ static verifyWebhookSignature({ secret, headers, rawBody, toleranceSeconds = 300 } = {}) {
277
+ const crypto = require('crypto');
278
+
279
+ if (!secret) return { valid: false, reason: 'secret is required' };
280
+ if (!headers) return { valid: false, reason: 'headers are required' };
281
+ if (rawBody === undefined || rawBody === null) {
282
+ return { valid: false, reason: 'rawBody is required' };
283
+ }
284
+
285
+ // A parsed object means express.json() ran and the original bytes are gone.
286
+ // Say so plainly, because the signature would simply never match and the
287
+ // cause is not obvious from a bare "invalid signature".
288
+ if (typeof rawBody === 'object' && !Buffer.isBuffer(rawBody)) {
289
+ return {
290
+ valid: false,
291
+ reason: 'rawBody looks parsed, not raw. The signature covers the bytes as sent, '
292
+ + "so use express.raw({ type: 'application/json' }) and pass req.body "
293
+ + 'unmodified.',
294
+ };
295
+ }
296
+
297
+ // Headers arrive lower-cased from Node, but a framework may not.
298
+ const pick = (name) => {
299
+ const want = name.toLowerCase();
300
+ if (typeof headers.get === 'function') return headers.get(name) ?? undefined;
301
+ const hit = Object.keys(headers).find((k) => k.toLowerCase() === want);
302
+ return hit ? headers[hit] : undefined;
303
+ };
304
+
305
+ const timestamp = pick('X-CertySign-Timestamp');
306
+ const signature = String(pick('X-CertySign-Signature') || '').replace(/^sha256=/, '');
307
+
308
+ if (!timestamp) return { valid: false, reason: 'missing X-CertySign-Timestamp header' };
309
+ if (!signature) return { valid: false, reason: 'missing X-CertySign-Signature header' };
310
+
311
+ const ts = Number(timestamp);
312
+ if (!Number.isFinite(ts)) return { valid: false, reason: 'X-CertySign-Timestamp is not a number' };
313
+
314
+ if (toleranceSeconds > 0) {
315
+ const age = Math.abs(Date.now() / 1000 - ts);
316
+ if (age > toleranceSeconds) {
317
+ return { valid: false, reason: `delivery is ${Math.round(age)}s old, outside the ${toleranceSeconds}s tolerance` };
318
+ }
319
+ }
320
+
321
+ const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
322
+ const expected = crypto
323
+ .createHmac('sha256', secret)
324
+ .update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body]))
325
+ .digest('hex');
326
+
327
+ // timingSafeEqual throws on a length mismatch, so compare digests of a fixed
328
+ // length rather than the raw hex.
329
+ const a = crypto.createHash('sha256').update(signature, 'utf8').digest();
330
+ const b = crypto.createHash('sha256').update(expected, 'utf8').digest();
331
+ if (!crypto.timingSafeEqual(a, b)) return { valid: false, reason: 'signature does not match' };
332
+
333
+ try {
334
+ return { valid: true, event: JSON.parse(body.toString('utf8')) };
335
+ } catch {
336
+ // Signature is good, so this came from us; the caller still gets the bytes.
337
+ return { valid: true, event: undefined, reason: 'signature valid but body is not JSON' };
338
+ }
339
+ }
197
340
  }
198
341
 
199
342
  /**
@@ -220,6 +363,7 @@ module.exports = {
220
363
  // or extend them; the client wires them up as client.signers / client.billing.
221
364
  SignerResource,
222
365
  BillingResource,
366
+ WebhookResource,
223
367
  // Legacy exports
224
368
  SigningResource,
225
369
  CertificateResource,
@@ -11,6 +11,14 @@
11
11
  'use strict';
12
12
 
13
13
  const axios = require('axios');
14
+
15
+ // Reported on every request, so a log can tell which SDK version a caller is on.
16
+ // Read from package.json because a hardcoded literal drifts: this said 1.0.0 for
17
+ // the whole 2.x line. Guarded because a bundler may not ship package.json.
18
+ const SDK_VERSION = (() => {
19
+ try { return require('../../package.json').version; } catch { return 'unknown'; }
20
+ })();
21
+ const SDK_USER_AGENT = `CertySign-SDK-Node/${SDK_VERSION} Node/${process.version}`;
14
22
  const FormData = require('form-data');
15
23
  const crypto = require('crypto');
16
24
 
@@ -51,7 +59,7 @@ class HttpClient {
51
59
  headers: {
52
60
  'Content-Type': 'application/json',
53
61
  'Accept': 'application/json',
54
- 'User-Agent': `CertySign-SDK-Node/1.0.0 Node/${process.version}`
62
+ 'User-Agent': SDK_USER_AGENT
55
63
  }
56
64
  });
57
65
  }
@@ -88,8 +88,18 @@ class IdentityResource {
88
88
 
89
89
  const ctry = String(country).toUpperCase().trim();
90
90
  const type = String(idType).toUpperCase().trim();
91
+ // Warn, do not throw. Which ID types exist for a country is the SERVER's
92
+ // vocabulary, and it grows. Refusing an unknown type here means that the day the
93
+ // API starts accepting one, every installed copy of this SDK rejects it before
94
+ // the request leaves the process — and the only fix is a republish. A warning
95
+ // still catches a typo during development while leaving the server as the
96
+ // authority on what it accepts.
91
97
  if (SUPPORTED[ctry] && !SUPPORTED[ctry].includes(type)) {
92
- throw new Error(`identity.verify: idType '${type}' is not supported for ${ctry} (supported: ${SUPPORTED[ctry].join(', ')})`);
98
+ console.warn(
99
+ `[certysign] identity.verify: idType '${type}' was not known for ${ctry} when this ` +
100
+ `SDK version was published (known: ${SUPPORTED[ctry].join(', ')}). Sending it anyway — ` +
101
+ `the API decides. Upgrade the SDK to silence this.`
102
+ );
93
103
  }
94
104
 
95
105
  const body = {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * client.webhooks — be told, instead of asking.
3
+ *
4
+ * Before these existed the only way to learn that an envelope completed or a
5
+ * sponsored certificate was issued was to poll, and every poll was billed. Register
6
+ * an endpoint once and CertySign calls you.
7
+ *
8
+ * Deliveries are not charged.
9
+ */
10
+ class WebhookResource {
11
+ constructor(http) {
12
+ this._http = http;
13
+ }
14
+
15
+ /**
16
+ * Register an endpoint.
17
+ *
18
+ * The signing secret is returned ONCE, in this response. Store it before you do
19
+ * anything else — there is no way to read it back, only to replace the webhook.
20
+ *
21
+ * Requires `webhook:manage`.
22
+ *
23
+ * @param {object} opts
24
+ * @param {string} opts.url https only
25
+ * @param {string[]} [opts.events] omit for every event
26
+ * @param {string} [opts.description]
27
+ *
28
+ * @example
29
+ * const { data } = await client.webhooks.create({
30
+ * url: 'https://api.example.com/certysign/hooks',
31
+ * events: ['envelope.completed', 'sponsorship.certificate_issued'],
32
+ * });
33
+ * await saveSecret(data.secret); // shown once
34
+ */
35
+ create({ url, events, description } = {}) {
36
+ if (!url) throw new Error('webhooks.create: url is required');
37
+ return this._http.post('/sdk/v1/webhooks', {
38
+ data: { url, ...(events && { events }), ...(description && { description }) },
39
+ });
40
+ }
41
+
42
+ /** Every webhook on this organisation, plus the list of supported events. */
43
+ list() {
44
+ return this._http.get('/sdk/v1/webhooks');
45
+ }
46
+
47
+ /** Stop sending to this endpoint. */
48
+ remove(webhookId) {
49
+ if (!webhookId) throw new Error('webhooks.remove: webhookId is required');
50
+ return this._http.delete(`/sdk/v1/webhooks/${encodeURIComponent(webhookId)}`);
51
+ }
52
+
53
+ /**
54
+ * Send a real, signed `webhook.test` event.
55
+ *
56
+ * Worth doing before anything real depends on it: it is the only way to confirm
57
+ * your signature check works while a failure still costs nothing.
58
+ */
59
+ test(webhookId) {
60
+ if (!webhookId) throw new Error('webhooks.test: webhookId is required');
61
+ return this._http.post(`/sdk/v1/webhooks/${encodeURIComponent(webhookId)}/test`, { data: {} });
62
+ }
63
+
64
+ /**
65
+ * What was sent, when, and what your endpoint said.
66
+ *
67
+ * @param {object} [opts]
68
+ * @param {string} [opts.webhookId]
69
+ * @param {'pending'|'delivered'|'failed'|'exhausted'} [opts.status]
70
+ * @param {number} [opts.limit]
71
+ */
72
+ deliveries({ webhookId, status, limit } = {}) {
73
+ const q = new URLSearchParams();
74
+ if (webhookId) q.set('webhookId', webhookId);
75
+ if (status) q.set('status', status);
76
+ if (limit) q.set('limit', String(limit));
77
+ const qs = q.toString();
78
+ return this._http.get(`/sdk/v1/webhooks/deliveries${qs ? `?${qs}` : ''}`);
79
+ }
80
+
81
+ /** Send one again, with the same eventId so you can de-duplicate. */
82
+ redeliver(deliveryId) {
83
+ if (!deliveryId) throw new Error('webhooks.redeliver: deliveryId is required');
84
+ return this._http.post(`/sdk/v1/webhooks/deliveries/${encodeURIComponent(deliveryId)}/redeliver`, { data: {} });
85
+ }
86
+ }
87
+
88
+ module.exports = { WebhookResource };