@taxora/sdk 1.1.0 → 1.3.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 +22 -0
- package/README.md +282 -1
- package/dist/index.cjs +1243 -190
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +684 -29
- package/dist/index.d.ts +684 -29
- package/dist/index.js +1216 -189
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@taxora/sdk` are documented here. Versions follow [SemVer](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [1.3.0] — 2026-10-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Webhook signature verification** for Taxora's opt-in HMAC-signed webhooks (`Taxora-Signature: t=…,v1=…`):
|
|
10
|
+
- `verifyWebhookSignature(rawBody, header, secret, { tolerance })` — constant-time comparison, timestamp
|
|
11
|
+
tolerance against replays (default 300 s), accepts several `v1` entries (rotation) and several secrets.
|
|
12
|
+
- `constructWebhookEvent(rawBody, header, secret, options)` — verifies, then returns a typed
|
|
13
|
+
`TaxoraWebhookEvent` (discriminated union on `event`).
|
|
14
|
+
- `generateWebhookSignatureHeader()` for testing your own handlers, plus `parseWebhookSignatureHeader()` and
|
|
15
|
+
`computeWebhookSignature()`.
|
|
16
|
+
- `WebhookSignatureVerificationError` with a machine-readable `reason`.
|
|
17
|
+
- Event types: `vat.validated`, `vat.state_changed`, `compliance.ledger.state_changed`,
|
|
18
|
+
`compliance.transaction.state_changed`, `enrichment.completed`, `enrichment.updated`, `webhook.test`.
|
|
19
|
+
|
|
20
|
+
## [1.2.0]
|
|
21
|
+
|
|
22
|
+
- Per-country compliance services incl. Norway e-invoicing (EHF / Peppol). See the GitHub release notes.
|
package/README.md
CHANGED
|
@@ -151,6 +151,12 @@ Each endpoint handles:
|
|
|
151
151
|
| `Token` | Access token with type and expiry; 15-second buffer for proactive refresh |
|
|
152
152
|
| `VatCertificateExport` | Bulk export job reference (`exportId` + optional message) |
|
|
153
153
|
| `ProviderDocument` | Attached provider document metadata (type, date, MIME, hash, nested line item) |
|
|
154
|
+
| `ComplianceEnrollment` | Per-country compliance enrollment: country, regime, `service`, provisioning status |
|
|
155
|
+
| `ComplianceTransaction` | Recorded transaction / e-invoice: amounts, state, buyer fields, `documentType`, `providerState` |
|
|
156
|
+
| `ComplianceTaxReport` | DGFiP tax report lifecycle of a transaction (France) |
|
|
157
|
+
| `SireneLookupResult` | Company data from the French SIRENE registry |
|
|
158
|
+
| `RegistryCompany` | Company from a national register (Norway: Brønnøysund) — org number, VAT / register flags |
|
|
159
|
+
| `PeppolLookupResult` | Peppol directory lookup: `status` (reachable / not_reachable / pending), document types |
|
|
154
160
|
|
|
155
161
|
All DTOs expose `toArray()` for serialization and a static `fromArray()` factory.
|
|
156
162
|
|
|
@@ -331,7 +337,35 @@ console.log(result?.status); // 'found' | 'no_vat_exists' | 'not_found'
|
|
|
331
337
|
console.log(result?.vatNumber); // e.g. 'ATU12345678'
|
|
332
338
|
console.log(result?.confidence); // 0–100
|
|
333
339
|
|
|
340
|
+
// Search mode: 'default' searches with one AI provider and only consults a second one when
|
|
341
|
+
// the first finds nothing. 'complex' has two independent providers search from the start and
|
|
342
|
+
// puts every number either of them proposes through the tax authority's checks. Complex finds
|
|
343
|
+
// companies a single search misses — typically ones whose VAT only appears in a website
|
|
344
|
+
// Impressum — and costs more per lookup, so it is opt-in.
|
|
345
|
+
const deep = await client.smartEnrichment.lookup({
|
|
346
|
+
companyName: 'Vodafone D2 GmbH',
|
|
347
|
+
country: 'DE',
|
|
348
|
+
postalCode: '40547',
|
|
349
|
+
mode: SmartEnrichmentMode.COMPLEX,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// When more than one provider searched, you can see what each one concluded on its own.
|
|
353
|
+
// Two of them reporting the same number is the strongest confirmation this layer produces.
|
|
354
|
+
for (const verdict of deep.result()?.providerVerdicts ?? []) {
|
|
355
|
+
console.log(`${verdict.provider}: ${verdict.vatNumber ?? 'nothing'}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The API also grades the address you sent. Worth writing back into your own records: a
|
|
359
|
+
// 'postal_code_city_mismatch' / 'postal_code_unassigned' warning means the postal code in
|
|
360
|
+
// your source data is wrong, and `derivedPlace` is the area it actually points at.
|
|
361
|
+
const quality = deep.result()?.addressQuality;
|
|
362
|
+
if (quality && quality.warnings.length > 0) {
|
|
363
|
+
console.log(quality.warnings.join(', '), '→', quality.derivedPlace);
|
|
364
|
+
}
|
|
365
|
+
|
|
334
366
|
// Bulk lookup (always async → webhook + polling)
|
|
367
|
+
// The batch-level mode applies to every row; a row carrying its own `mode` overrides it,
|
|
368
|
+
// so you can pay for 'complex' on just the rows that need it.
|
|
335
369
|
const bulk = await client.smartEnrichment.bulkLookup([
|
|
336
370
|
{ companyName: 'A GmbH', country: 'DE', city: 'Berlin' },
|
|
337
371
|
{ companyName: 'B SARL', country: 'FR' },
|
|
@@ -415,6 +449,12 @@ submit transactions (individually or via CSV import), and track the resulting DG
|
|
|
415
449
|
All `compliance` routes require an active E-Reporting subscription / feature grant on your
|
|
416
450
|
account — except `requestEReportingAccess()`, which is how you ask for one.
|
|
417
451
|
|
|
452
|
+
**Per-country services:** each enrollment books one service for one country — `e_reporting`
|
|
453
|
+
(France, regime `dgfip_flux10`) or `e_invoicing` (Norway EHF / Peppol BIS 3.0, regime
|
|
454
|
+
`peppol_bis3`, see [below](#-e-invoicing-norway-ehf--peppol)). Every country is unlocked and
|
|
455
|
+
billed separately (one-off setup fee + per document). Enrolling for a service your company does not
|
|
456
|
+
have yields a 403 `HttpException` whose body carries `code: "service_not_active"`.
|
|
457
|
+
|
|
418
458
|
### Request feature activation
|
|
419
459
|
|
|
420
460
|
Not enabled yet? This route is deliberately **not** gated — it requests activation of the
|
|
@@ -427,6 +467,8 @@ await client.eReporting.requestEReportingAccess({
|
|
|
427
467
|
phone: '+43 660 1234567',
|
|
428
468
|
message: 'Please activate e-reporting for our account.',
|
|
429
469
|
language: 'de', // locale of the confirmation mail
|
|
470
|
+
service: 'e_invoicing', // optional: 'e_reporting' (default) | 'e_invoicing'
|
|
471
|
+
countries: ['NO'], // optional: the countries you want the service for
|
|
430
472
|
});
|
|
431
473
|
```
|
|
432
474
|
|
|
@@ -513,6 +555,8 @@ const transactions = await client.eReporting.listTransactions({
|
|
|
513
555
|
dateTo: '2026-07-31',
|
|
514
556
|
state: 'pending', // 'pending' | 'sending' | 'submitted' | 'error'
|
|
515
557
|
transactionType: 'b2c_outbound',
|
|
558
|
+
complianceEnrollmentId: enrollment.id, // optional: one enrollment only
|
|
559
|
+
country: 'FR', // optional: one country only
|
|
516
560
|
page: 1,
|
|
517
561
|
perPage: 25,
|
|
518
562
|
});
|
|
@@ -596,6 +640,119 @@ for (const row of stats.byCountry) {
|
|
|
596
640
|
}
|
|
597
641
|
```
|
|
598
642
|
|
|
643
|
+
### 🇳🇴 E-Invoicing Norway (EHF / Peppol)
|
|
644
|
+
|
|
645
|
+
Send EHF e-invoices and credit notes (Peppol BIS Billing 3.0) to Norwegian buyers. The
|
|
646
|
+
`e_invoicing` service for Norway is billed separately from French e-reporting (one-off setup
|
|
647
|
+
fee + per document) — ask for it via `requestEReportingAccess({ service: 'e_invoicing', countries: ['NO'] })`.
|
|
648
|
+
|
|
649
|
+
**1. Registry lookup** — resolve an organisation number, `NO…MVA` VAT number or company name in
|
|
650
|
+
the Brønnøysund register (rate-limited to 30 requests/minute; 404 when nothing is found):
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
import { NorwegianOrgNumber } from '@taxora/sdk';
|
|
654
|
+
|
|
655
|
+
const [company] = await client.eReporting.registryLookup('NO', '923609016');
|
|
656
|
+
console.log(company.companyName, company.vatNumber, company.vatRegistered, company.enterpriseRegister);
|
|
657
|
+
|
|
658
|
+
// Client-side helper (mod-11 check digit, accepts spaces / NO prefix / MVA suffix)
|
|
659
|
+
NorwegianOrgNumber.isValid('NO 923 609 016 MVA'); // true
|
|
660
|
+
NorwegianOrgNumber.normalize('923609016MVA'); // '923609016'
|
|
661
|
+
NorwegianOrgNumber.toVatNumber('923609016'); // 'NO923609016MVA'
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
**2. Enrollment** — registers your company as a Peppol participant. The organisation number is
|
|
665
|
+
validated and normalized before sending:
|
|
666
|
+
|
|
667
|
+
```ts
|
|
668
|
+
const noEnrollment = await client.eReporting.createNorwayEnrollment({
|
|
669
|
+
orgNumber: company.orgNumber,
|
|
670
|
+
companyName: company.companyName,
|
|
671
|
+
address: company.address!,
|
|
672
|
+
city: company.city!,
|
|
673
|
+
postalcode: company.postalcode!,
|
|
674
|
+
email: 'faktura@acme.no',
|
|
675
|
+
vatRegistered: company.vatRegistered, // optional, default true → "MVA" on invoices
|
|
676
|
+
enterpriseRegister: company.enterpriseRegister, // optional, default false → "Foretaksregisteret"
|
|
677
|
+
reception: false, // optional: also receive e-invoices over Peppol
|
|
678
|
+
});
|
|
679
|
+
// Equivalent: client.eReporting.createEnrollment({ country: 'NO', ... })
|
|
680
|
+
|
|
681
|
+
console.log(noEnrollment.service, noEnrollment.regime); // 'e_invoicing' 'peppol_bis3'
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
**3. Peppol lookup** — check that the buyer can receive EHF invoices (free of charge). The
|
|
685
|
+
directory resolves asynchronously: `pending` means ask again in a few seconds.
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
let reach = await client.eReporting.peppolLookup('NO', '974760673'); // scheme defaults to 0192
|
|
689
|
+
if (reach.isPending) {
|
|
690
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
691
|
+
reach = await client.eReporting.peppolLookup('NO', '974760673');
|
|
692
|
+
}
|
|
693
|
+
console.log(reach.status, reach.reachable, reach.documentTypes); // 'reachable' true [...]
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
**4. Send an invoice** — Norway requires `transactionType: 'b2b_domestic_outbound'`, the buyer's
|
|
697
|
+
organisation number (`counterpartyRegisterId`) and `counterpartyName` (enforced server-side, 422):
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
const invoice = await client.eReporting.createTransaction({
|
|
701
|
+
complianceEnrollmentId: noEnrollment.id,
|
|
702
|
+
transactionType: 'b2b_domestic_outbound',
|
|
703
|
+
invoiceNumber: 'NO-2026-0001',
|
|
704
|
+
invoiceDate: '2026-09-25',
|
|
705
|
+
dueDate: '2026-10-09',
|
|
706
|
+
currency: 'NOK',
|
|
707
|
+
subtotal: 2000,
|
|
708
|
+
taxAmount: 500,
|
|
709
|
+
total: 2500,
|
|
710
|
+
counterpartyName: 'EXAMPLE BUYER AS',
|
|
711
|
+
counterpartyCountry: 'NO',
|
|
712
|
+
counterpartyRegisterId: '974760673',
|
|
713
|
+
counterpartyAddress: 'Storgata 1',
|
|
714
|
+
counterpartyCity: 'Oslo',
|
|
715
|
+
counterpartyPostalcode: '0155',
|
|
716
|
+
counterpartyEmail: 'ap@buyer.example', // optional
|
|
717
|
+
buyerReference: 'PO-4711', // optional, defaults to the buyer's org number
|
|
718
|
+
invoiceLines: [
|
|
719
|
+
{ description: 'Consulting', quantity: 2, price: 1000, taxes: [{ name: 'MVA', percent: 25, category: 'S' }] },
|
|
720
|
+
],
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// `state` tracks submission; `providerState` the Peppol delivery (e.g. sent, accepted, refused, paid)
|
|
724
|
+
const current = await client.eReporting.getTransaction(invoice.id);
|
|
725
|
+
console.log(current.state, current.providerState, current.documentType); // … 'invoice'
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
**5. Send a credit note** — set `isCreditNote` and reference the corrected invoice:
|
|
729
|
+
|
|
730
|
+
```ts
|
|
731
|
+
const creditNote = await client.eReporting.createTransaction({
|
|
732
|
+
complianceEnrollmentId: noEnrollment.id,
|
|
733
|
+
transactionType: 'b2b_domestic_outbound',
|
|
734
|
+
invoiceNumber: 'NO-2026-0001-CN',
|
|
735
|
+
invoiceDate: '2026-09-30',
|
|
736
|
+
currency: 'NOK',
|
|
737
|
+
subtotal: 1000,
|
|
738
|
+
taxAmount: 250,
|
|
739
|
+
total: 1250,
|
|
740
|
+
counterpartyName: 'EXAMPLE BUYER AS',
|
|
741
|
+
counterpartyRegisterId: '974760673',
|
|
742
|
+
isCreditNote: true,
|
|
743
|
+
amendedNumber: 'NO-2026-0001', // required for credit notes in Norway
|
|
744
|
+
amendedDate: '2026-09-25',
|
|
745
|
+
extraInfo: 'Credit for one returned consulting day',
|
|
746
|
+
invoiceLines: [
|
|
747
|
+
{ description: 'Consulting', quantity: 1, price: 1000, taxes: [{ name: 'MVA', percent: 25, category: 'S' }] },
|
|
748
|
+
],
|
|
749
|
+
});
|
|
750
|
+
console.log(creditNote.isCreditNote); // true
|
|
751
|
+
|
|
752
|
+
// All Norwegian documents
|
|
753
|
+
const noDocs = await client.eReporting.listTransactions({ country: 'NO' });
|
|
754
|
+
```
|
|
755
|
+
|
|
599
756
|
---
|
|
600
757
|
|
|
601
758
|
## 📜 Certificates
|
|
@@ -668,11 +825,135 @@ try {
|
|
|
668
825
|
console.error('Validation errors:', err.getErrors()); // Record<string, string[]>
|
|
669
826
|
} else if (err instanceof HttpException) {
|
|
670
827
|
// Any other HTTP error
|
|
671
|
-
console.error(`HTTP ${err.getStatusCode()}:`, err.
|
|
828
|
+
console.error(`HTTP ${err.getStatusCode()}:`, err.message);
|
|
672
829
|
}
|
|
673
830
|
}
|
|
674
831
|
```
|
|
675
832
|
|
|
833
|
+
**Exception messages are always short and safe to log.** A response body is
|
|
834
|
+
never used as the message: gateways in front of the API answer `502`/`503`/`504`
|
|
835
|
+
with a full HTML error page, and that page would otherwise land in every log
|
|
836
|
+
line of your application. The SDK uses the `message` / `error` field of a JSON
|
|
837
|
+
error body when there is one, and falls back to the status line
|
|
838
|
+
(`Taxora API request failed (HTTP 504 Gateway Timeout).`) otherwise. The
|
|
839
|
+
untouched body stays available via `err.getResponseBody()`.
|
|
840
|
+
|
|
841
|
+
### Automatic retries
|
|
842
|
+
|
|
843
|
+
The SDK retries what never produced an answer from the API itself: **3 attempts,
|
|
844
|
+
with 500 ms and 1000 ms of backoff in between**. That covers
|
|
845
|
+
|
|
846
|
+
- **gateway failures (`502`, `503`, `504`)** — the infrastructure in front of the
|
|
847
|
+
API, where a second attempt a moment later usually succeeds;
|
|
848
|
+
- **connection-level failures** — reset connections, client-side timeouts, DNS
|
|
849
|
+
hiccups (`fetch failed`, `ECONNRESET`, `UND_ERR_CONNECT_TIMEOUT`, …). Once the
|
|
850
|
+
attempts are used up, the original error is rethrown unchanged;
|
|
851
|
+
- **`429 Too Many Requests`, but only with a `Retry-After`** — then the SDK waits
|
|
852
|
+
exactly as long as the API asked instead of using its own backoff. Without that
|
|
853
|
+
header a retry would only hammer a rate limit that is already tripped, so the
|
|
854
|
+
error is surfaced immediately. A `Retry-After` longer than `maxRetryAfterMs`
|
|
855
|
+
(10 s by default) also fails immediately rather than blocking your worker — the
|
|
856
|
+
raw header stays readable via `err.getRetryAfter()`.
|
|
857
|
+
|
|
858
|
+
Everything else fails on the first attempt, including any `5xx` the API produced
|
|
859
|
+
itself (a `500` is a bug, not a hiccup — repeating it just costs time).
|
|
860
|
+
|
|
861
|
+
Retries apply to read-only calls only — VAT validation, lookups, state, history,
|
|
862
|
+
search, certificate downloads, company and e-reporting reads, login and token
|
|
863
|
+
refresh. Calls that change state or cost quota (`certificatesBulkExport()`,
|
|
864
|
+
`smartEnrichment.lookup()`, e-reporting enrollment/transaction writes) are
|
|
865
|
+
**never** retried automatically, because a gateway timeout does not tell us
|
|
866
|
+
whether the API already processed the request.
|
|
867
|
+
|
|
868
|
+
Once the attempts are used up you get one clean error:
|
|
869
|
+
|
|
870
|
+
```
|
|
871
|
+
Taxora VAT validation failed after 3 attempts (HTTP 504 Gateway Timeout).
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Tune or disable it via `RetryPolicy`:
|
|
875
|
+
|
|
876
|
+
```ts
|
|
877
|
+
import { RetryPolicy, TaxoraClientFactory } from '@taxora/sdk';
|
|
878
|
+
|
|
879
|
+
const client = TaxoraClientFactory.create({
|
|
880
|
+
apiKey: 'YOUR_X_API_KEY',
|
|
881
|
+
retryPolicy: new RetryPolicy({
|
|
882
|
+
maxAttempts: 5,
|
|
883
|
+
initialDelayMs: 250,
|
|
884
|
+
retryableStatusCodes: [502, 503, 504], // add or drop status codes
|
|
885
|
+
respectRetryAfter: true, // false = ignore the header entirely
|
|
886
|
+
maxRetryAfterMs: 10_000, // longest wait accepted from Retry-After
|
|
887
|
+
retryOnNetworkErrors: true, // false = surface transport errors at once
|
|
888
|
+
}),
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// or, for callers that do their own retrying (queue workers, cron jobs):
|
|
892
|
+
const noRetries = TaxoraClientFactory.create({ apiKey: '…', retryPolicy: RetryPolicy.disabled() });
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
## 🔐 Webhook Signatures
|
|
898
|
+
|
|
899
|
+
Taxora can sign every webhook request with HMAC-SHA256 (opt-in per endpoint: dashboard → **Developer → Webhooks**,
|
|
900
|
+
or `POST /v1/webhooks/{id}/signing/enable`). Signed requests carry:
|
|
901
|
+
|
|
902
|
+
```
|
|
903
|
+
Taxora-Signature: t=1716115200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
|
|
904
|
+
Taxora-Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
`constructWebhookEvent` verifies the signature (constant-time, 5-minute replay window) and returns a typed event:
|
|
908
|
+
|
|
909
|
+
```ts
|
|
910
|
+
import express from 'express';
|
|
911
|
+
import { constructWebhookEvent, WebhookSignatureVerificationError } from '@taxora/sdk';
|
|
912
|
+
|
|
913
|
+
const app = express();
|
|
914
|
+
|
|
915
|
+
// express.raw — NOT express.json: the signature covers the exact bytes Taxora sent.
|
|
916
|
+
app.post('/taxora-webhook', express.raw({ type: 'application/json' }), (req, res) => {
|
|
917
|
+
let event;
|
|
918
|
+
try {
|
|
919
|
+
event = constructWebhookEvent(req.body, req.header('Taxora-Signature'), process.env.TAXORA_WEBHOOK_SECRET!, {
|
|
920
|
+
tolerance: 300, // seconds, default
|
|
921
|
+
});
|
|
922
|
+
} catch (err) {
|
|
923
|
+
if (err instanceof WebhookSignatureVerificationError) {
|
|
924
|
+
return res.status(400).send(err.message); // err.reason: 'no_matching_signature' | 'timestamp_outside_tolerance' | …
|
|
925
|
+
}
|
|
926
|
+
throw err;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
switch (event.event) {
|
|
930
|
+
case 'vat.validated':
|
|
931
|
+
console.log(event.data.object.vat_uid, event.data.object.state); // typed
|
|
932
|
+
break;
|
|
933
|
+
case 'enrichment.completed':
|
|
934
|
+
console.log(event.data.object.foundCount);
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
res.sendStatus(200);
|
|
939
|
+
});
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
Next.js App Router: `const rawBody = await request.text();` (not `request.json()`).
|
|
943
|
+
|
|
944
|
+
| Helper | Purpose |
|
|
945
|
+
| ---------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
946
|
+
| `verifyWebhookSignature(rawBody, header, secret, { tolerance })` | Returns `true` or throws `WebhookSignatureVerificationError` |
|
|
947
|
+
| `constructWebhookEvent(rawBody, header, secret, { tolerance })` | Verifies, then parses into `TaxoraWebhookEvent` |
|
|
948
|
+
| `generateWebhookSignatureHeader(rawBody, secret, timestamp?)` | Builds a valid header — for testing your own handler |
|
|
949
|
+
| `parseWebhookSignatureHeader(header)` / `computeWebhookSignature(...)` | Low-level building blocks |
|
|
950
|
+
|
|
951
|
+
- **Rotation:** while Taxora rotates your secret, requests carry one `v1=` per live secret — any match is accepted.
|
|
952
|
+
`secret` may also be an array, so you can accept old and new secret on your side during a deploy.
|
|
953
|
+
- **Raw body:** pass a `string`, `Buffer` or `Uint8Array`. Passing an already parsed object throws
|
|
954
|
+
`reason: 'invalid_body'`.
|
|
955
|
+
- **Idempotency:** dedupe on `event.id` (= `Taxora-Webhook-Id`), which stays the same across retries.
|
|
956
|
+
|
|
676
957
|
---
|
|
677
958
|
|
|
678
959
|
## 🔌 VatState Helpers
|