Skip to content

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.

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.

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:

  1. Convert the event to its canonical JSON form (RFC 8785, “JCS”).
  2. Sign those bytes with EdDSA as a compact JWS whose header is {"alg":"EdDSA","kid":"<source id>","typ":"ape+jws","ape":"0.1"}.
  3. 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.

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" }
}
]
}
StatusMeaningWhat to do
acceptedStored and added to the chain at chain_seq.Nothing.
duplicateAn event with this id was already stored.Nothing. This is how retries stay safe.
rejectedNot 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.

CodeCause
invalid_eventThe event does not match the schema. error.details lists the paths.
invalid_signatureThe signature is missing, malformed, or does not match the event and registered key.
unknown_sourceThe source id is not registered, was revoked, or belongs to the other environment.
environment_mismatchThe event’s environment differs from the key’s.
pan_detectedA free-text field contains a card number. The event is not stored.

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, or 429. Back off between tries.
  • Do not retry on 400, 401, 403, 413 or 422 without changing something. A 413 means 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_id instead.

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.

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.