Send events
Most teams use an SDK, which does everything on this page. Read it if you integrate without an SDK or want to know exactly what the SDK does.
The request
Section titled “The request”POST /v1/events takes up to 100 events in one request, with a body of at most 1 MB. Each item is an event and its
signature.
{ "events": [ { "event": { "ape_version": "0.1", "id": "evt_01JA8Q4K3T9V2C7M5N6P8R0S1W", "...": "the rest of the event" }, "sig": "eyJhbGciOiJFZERTQSIsImtpZCI6InNyY18wMS4uLiJ9..c2lnbmF0dXJl" } ]}The API key needs the events:write scope, and the event’s environment must match the key.
Sign each event
Section titled “Sign each event”Generate an Ed25519 key pair once per source, register the public key with POST /v1/sources, and keep the private key
secret. Then sign every event:
- Convert the event to its canonical JSON form (RFC 8785, “JCS”).
- Sign those bytes with EdDSA as a compact JWS whose header is
{"alg":"EdDSA","kid":"<source id>","typ":"ape+jws","ape":"0.1"}. - Remove the payload from the middle of the JWS so it reads
<header>..<signature>.
import { CompactSign, importJWK } from 'jose';import canonicalize from 'canonicalize';
const key = await importJWK(JSON.parse(process.env.HALVOR_SOURCE_KEY!), 'EdDSA');
export async function sign(event: { source: { id: string } }): Promise<string> { const jws = await new CompactSign(new TextEncoder().encode(canonicalize(event)!)) .setProtectedHeader({ alg: 'EdDSA', kid: event.source.id, typ: 'ape+jws', ape: '0.1' }) .sign(key); const [header, , signature] = jws.split('.'); return `${header}..${signature}`;}kid must equal event.source.id. Key order and whitespace do not matter, because the canonical form fixes both.
Read the response
Section titled “Read the response”The response is 202 even when some items are rejected. Look at each item:
{ "results": [ { "id": "evt_01JA8Q4K3T9V2C7M5N6P8R0S1W", "status": "accepted", "chain_seq": 18233 }, { "id": "evt_01JA8Q4K3T9V2C7M5N6P8R0S1X", "status": "duplicate" }, { "id": "evt_01JA8Q4K3T9V2C7M5N6P8R0S1Y", "status": "rejected", "error": { "code": "invalid_signature", "message": "Signature does not match" } } ]}| Status | Meaning | What to do |
|---|---|---|
accepted | Stored and added to the chain at chain_seq. | Nothing. |
duplicate | An event with this id was already stored. | Nothing. This is how retries stay safe. |
rejected | Not stored. See the error. | Fix the event. Do not retry it unchanged. |
An accepted item may also carry "warning": "eu_owner_country" when the owner’s country is in the EEA or UK. The event
is stored, but the current terms exclude data about those data subjects. See data handling.
Rejection codes
Section titled “Rejection codes”| Code | Cause |
|---|---|
invalid_event | The event does not match the schema. error.details lists the paths. |
invalid_signature | The signature is missing, malformed, or does not match the event and registered key. |
unknown_source | The source id is not registered, was revoked, or belongs to the other environment. |
environment_mismatch | The event’s environment differs from the key’s. |
pan_detected | A free-text field contains a card number. The event is not stored. |
Retries and idempotency
Section titled “Retries and idempotency”The event id is the idempotency key, unique per organization and environment. Send the same event again and you get
duplicate, never a second copy. So:
- Retry the whole request on a network error,
5xx, or429. Back off between tries. - Do not retry on
400,401,403,413or422without changing something. A413means the body is too large, so split the batch. - Never change an event and resend it under the same id. Send a new event with
related.corrects_event_idinstead.
The SDKs retry with delays of 1, 2, 4, 8, 16 and 32 seconds, which rides out an outage of about a minute. They can also keep undelivered batches on disk. See TypeScript SDK.
Never send
Section titled “Never send”Card numbers, CVCs, secret keys, private keys, or the full text of an instruction. The SDK strips these before sending. If you build events yourself, strip them yourself, because ingest rejects card numbers outright.