Emit a contact event
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.
bearerAuth writeAuthorizationBearer <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— everythingreaddoes, plus managing templates, contacts, automations, segments and suppressions. This is what editing a template needs.admin— everythingwritedoes, 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
application/json- body
email?stringIdentifies the contact; creates it if unknown. Provide this or external_id.
^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$emailexternal_id?stringIdentifies an existing contact by your id. Provide this or email.
1 <= length <= 255name*stringEvent name. Letters, digits, and _ . : -.
^[a-zA-Z0-9_.:-]+$1 <= length <= 120properties?Event properties — available to automation trigger filters.
dedupe_key?stringIdempotency 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.
1 <= length <= 255occurred_at?stringISO 8601 timestamp (with offset) of when the event happened.
^(?:(?:\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)))$date-timecontact_attributes?Attributes to upsert onto the contact alongside the event.
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- response
id*stringQueued event id.
status*"queued""queued"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"}'{ "id": "9f1c2e77-5a1e-4a1a-9c66-1f0f3f4b2d10", "status": "queued"}