Send Event
Send (ingest) a custom event for a contact. The event name must already be defined for your organization, and the payload is validated against that definition's schema. Accepted events can trigger automations in real time.
Send one event, or a batch of up to 50.
Endpoint
POST /events/send
Returns: 202 Accepted
Headers
| Header | Value | Required |
|---|---|---|
Authorization | Bearer <your-api-key> | Yes |
Content-Type | application/json | Yes |
Idempotency-Key | Any unique string, max 256 chars | No — see Idempotency |
Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
event | string | Yes | Name of a defined event for your organization. |
external_id | string | Conditional | Your own identifier for this person (max 255 chars, unique within your organization). Outranks email and phone_number. |
email | string | Conditional | Email of the contact the event is about. If no contact matches, one is auto-created when the automation runs. |
phone_number | string | Conditional | Phone number in E.164 format (e.g. +966501234567). If no contact matches, a phone-only contact is auto-created when the automation runs. |
contact_id | string | Conditional | Contact UUID the event is about. Mutually exclusive with external_id/email/phone_number. |
data | object | Yes | Event payload (max request body 5 MB). Validated against the event's declared schema — see Payload validation. |
timestamp | string | No | ISO 8601 time the event occurred. Defaults to server time. Used for rollup bucketing and echoed in the automation webhook payload — see the warning below. |
Provide at least one of external_id, email or phone_number (you may send several), or contact_id on its own. Combining contact_id with any of the others, or providing no identifier, returns 400.
Which identifier wins
Identifiers are ranked. The event resolves against the highest-ranked one you send that matches an existing contact:
| Rank | Identifier | Notes |
|---|---|---|
| 1 | contact_id | A direct pointer. Used on its own; a UUID that doesn't exist returns 404. |
| 2 | external_id | Your identifier for the person. Stable across email and phone changes. |
| 3 | email | |
| 4 | phone_number | Among duplicates, the oldest contact wins. |
Sending external_id alongside email or phone_number is the recommended
pattern. If the contact is found by the weaker identifier and carries no
external_id yet, it adopts the one you sent — so a later external_id-only
event finds the same contact instead of creating a duplicate. An external_id
already held by a different contact is left alone and the event still resolves.
If nothing matches, a contact is auto-created carrying every identifier you sent.
{
"event": "order.completed",
"external_id": "user_10294",
"email": "john.doe@example.com",
"data": {
"order_id": "A-1023",
"total": 149.99
},
"timestamp": "2026-06-01T12:00:00.000Z"
}
Payload validation
data is validated against the fields declared in the event's definition:
| Field type | Accepts | Rejects |
|---|---|---|
string | a JSON string | numbers, booleans, objects |
number | a finite JSON number | numeric strings ("12"), NaN, Infinity |
boolean | true or false | "true", 0/1 |
date | a Date.parse-able string (e.g. "2026-03-05" or "March 5 2026") | epoch numbers — send the date as a string |
- A missing required field returns
422. - Undeclared keys are accepted and stored, but ignored by validation. A misspelled field name (e.g.
totlinstead oftotal) does not error — it is kept in the payload and will simply never match an automation condition that expects the declared field. Check your field names against the definition.
Response
The event is accepted for asynchronous processing. id is the event log ID.
{
"id": "01957e3a-4b5c-7d8e-9f0a-1b2c3d4e5f6a",
"status": "accepted"
}
| Field | Type | Description |
|---|---|---|
id | string | Event log ID (UUIDv7) |
status | string | Always accepted for a 202 response |
Sending a batch
The same endpoint accepts a batch envelope instead of a single event — send { "events": [ … ] }, with up to 50 items of exactly the item shape documented above.
{
"events": [
{
"event": "product.viewed",
"external_id": "user_10294",
"data": { "sku": "A-1023" }
},
{
"event": "order.completed",
"email": "john.doe@example.com",
"data": { "order_id": "A-1023", "total": 149.99 }
}
]
}
Items are independent
A batch does not fail as a unit. Each item is validated and processed on its own, and the response carries one slot per item in the order you sent them:
{
"results": [
{ "id": "01957e3a-4b5c-7d8e-9f0a-1b2c3d4e5f6a", "status": "accepted" },
{ "error": "No event named \"order.completed\" is defined in this organization." }
]
}
A per-item validation error yields { "error": … } in that item's slot and does not affect the others. The HTTP status is still 202 — always inspect the results array rather than relying on the status code alone.
Idempotency-Key header applies to the whole requestOne key covers the entire batch, not individual items. Retrying with the same key replays the whole request; it cannot re-send just the items that errored. If you need per-item idempotency, send them individually.
Batching is what the client SDKs use to drain their queues efficiently — it is the same endpoint, so a server-side integration can use it too.
Idempotency
Sending the same event twice — for example because a network timeout made you retry — could record the event twice, double-counting your rollups and revenue. To make a retry safe, send an Idempotency-Key header whose value is unique to that event (for example order.completed/A-1023):
- The first request with a given key is processed normally and its response is stored.
- Any later request with the same key (within 24 hours) is not reprocessed — it returns the original response unchanged, plus an
Idempotent-Replayed: trueresponse header. The side effect happens only once. - After 24 hours the key expires and may be reused.
The key can be any unique string up to 256 characters. A UUID works, but a value derived from the entity the request is about — like order.completed/A-1023 — is easier to regenerate identically on a retry. Keys are scoped per endpoint, so the same value can be reused independently across the send-email, send-SMS, and send-event endpoints without colliding.
Reuse a key with a different request body and the request is rejected with 409 invalid_idempotent_request, which protects you from receiving the wrong cached response. Use a fresh key for a genuinely different request.
Idempotency error responses
| Status | name | When |
|---|---|---|
400 | invalid_idempotency_key | The key is empty or longer than 256 characters. |
409 | invalid_idempotent_request | The key was already used with a different request body. |
409 | concurrent_idempotent_requests | An earlier request with the same key is still being processed — retry shortly. |
Examples
- cURL
- JavaScript
- Python
- C#
- PHP
curl -X POST "https://api.arsel.sa/v1/events/send" \
-H "Authorization: Bearer be_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order.completed/A-1023" \
-d '{
"event": "order.completed",
"email": "john.doe@example.com",
"data": { "order_id": "A-1023", "total": 149.99 }
}'
const response = await fetch("https://api.arsel.sa/v1/events/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer be_your_api_key",
},
body: JSON.stringify({
event: "order.completed",
email: "john.doe@example.com",
data: { order_id: "A-1023", total: 149.99 },
}),
});
const result = await response.json();
console.log(result.id, result.status);
import requests
response = requests.post(
"https://api.arsel.sa/v1/events/send",
headers={
"Content-Type": "application/json",
"Authorization": "Bearer be_your_api_key",
},
json={
"event": "order.completed",
"email": "john.doe@example.com",
"data": {"order_id": "A-1023", "total": 149.99},
},
)
result = response.json()
print(result["id"], result["status"])
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", "Bearer be_your_api_key");
var payload = new
{
@event = "order.completed",
email = "john.doe@example.com",
data = new { order_id = "A-1023", total = 149.99 }
};
var json = System.Text.Json.JsonSerializer.Serialize(payload);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://api.arsel.sa/v1/events/send", content);
Console.WriteLine(await response.Content.ReadAsStringAsync());
<?php
$ch = curl_init("https://api.arsel.sa/v1/events/send");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"Authorization: Bearer be_your_api_key"
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"event" => "order.completed",
"email" => "john.doe@example.com",
"data" => ["order_id" => "A-1023", "total" => 149.99]
]));
$response = curl_exec($ch);
echo $response;
curl_close($ch);
Error Responses
- 400 Contact
- 404 Event
- 404 Contact
- 422 Schema Mismatch
- 401 Unauthorized
Provide at least one of external_id/email/phone_number, or contact_id on its own:
{
"status_code": 400,
"name": "bad_request",
"message": "Provide at least one of external_id, email, phone_number, or contact_id."
}
{
"status_code": 404,
"name": "not_found",
"message": "No event named \"order.completed\" is defined in this organization."
}
Returned when you pass a contact_id that does not exist in your organization. (Identifying by external_id/email/phone_number instead auto-creates a contact, so it never returns this.)
{
"status_code": 404,
"name": "not_found",
"message": "No contact found for the provided contact_id."
}
The payload does not satisfy the event's schema (missing required field, wrong type, etc.):
{
"status_code": 422,
"name": "validation_error",
"message": "Payload does not match the \"order.completed\" schema."
}
{
"status_code": 401,
"name": "unauthorized",
"message": "Invalid or missing API key"
}