Events

Emit a contact event

POST
/api/v1/events

Records an event for a contact (creating the contact if you pass an unknown email). Provide email or external_id.

Accepted and queued: you get 202 with an id the moment the event is durably stored, and the contact upsert plus automation enrollment run in the background. This endpoint is designed to be called from inside your own user-facing request, so it never makes you wait on that work — the same reason 202 Accepted is the only answer SendGrid's send endpoint gives.

To see what an event actually did — which automations enrolled and why the others declined — read the contact's timeline with GET /api/v1/contacts/{id}/events; each event carries its recorded outcome.

Authorization

bearerAuth write
headerAuthorizationBearer <token>

An API key from the dashboard under Settings → API keys, sent as Authorization: Bearer aem_….

Authorization has two independent axes.

The scope is ranked — a key satisfies any requirement at or below its own tier:

  • read — see messages, contacts and metrics. Changes nothing, and cannot send.
  • write — everything read does, plus managing templates, contacts, automations, segments and suppressions. This is what editing a template needs.
  • admin — everything write does, plus sending configuration: domains, senders, webhook registration, kill switch, daily cap, brand.

There is no approve scope. It was a rung once; it is not one now, and a key requested with it is rejected.

The approval grant (can_approve) is a separate boolean, not a rung. Delivering mail to a real inbox needs write and the grant. Keeping them on separate axes is what makes the review gate a control rather than a convention: a key that may propose is not automatically a key that may approve its own proposal.

Give your application the lowest tier that works. Most need write and the grant — the dashboard mints that combination as Send + manage; Manage only is the same rung with the grant withheld.

Scope: write

Request Body

application/json
  1. body
email?string

Identifies the contact; creates it if unknown. Provide this or external_id.

Match^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$
Formatemail
external_id?string

Identifies an existing contact by your id. Provide this or email.

Length1 <= length <= 255
name*string

Event name. Letters, digits, and _ . : -.

Match^[a-zA-Z0-9_.:-]+$
Length1 <= length <= 120
properties?

Event properties — available to automation trigger filters.

dedupe_key?string

Idempotency key for this event. A repeat is a no-op for 30 days — the retention window on the event log. Past that the event row is pruned and its key becomes free again, so a replay of a genuinely old event is accepted as new. Automations are unaffected either way: enrolment is guarded by the run history, which is never pruned.

Length1 <= length <= 255
occurred_at?string

ISO 8601 timestamp (with offset) of when the event happened.

Match^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
Formatdate-time
contact_attributes?

Attributes to upsert onto the contact alongside the event.

Response Body

Accepted. The event is durably stored and will be processed in the background. A repeated dedupe_key is accepted the same way and collapses when processed, so a retry after a timeout is always safe.

application/json
  1. response
id*string

Queued event id.

status*"queued"
Value in"queued"
Example Request
curl -X POST 'https://sendandretain.com/api/v1/events' \  -H 'Content-Type: application/json' \  -d '{  "email": "[email protected]",  "name": "order.completed",  "properties": {    "orderId": "ord_991",    "total": 42.5  },  "dedupe_key": "ord_991"}'
Example Response
{  "id": "9f1c2e77-5a1e-4a1a-9c66-1f0f3f4b2d10",  "status": "queued"}