Send Push
Send a transactional push notification to a single contact's registered devices.
- Order and shipping updates
- Delivery and booking reminders
- Account and security alerts
Endpoint
POST /push/send
Returns: 202 Accepted
Prerequisites
Before any notification can be delivered you must complete push setup in the Arsel Dashboard under Integration > Push, for each platform you ship on: a Firebase service account for Android, an APNs auth key (.p8) for iOS, and Web Push for browsers. iOS goes to Apple directly — there is no Firebase in that path. Because the sending identity stays yours, device tokens you already hold keep working. See Setting up push.
Devices are registered by the client SDKs or via Register Device. This endpoint addresses a contact, never a device token — an anonymous registration is stored but is not addressable until it is bound to a contact.
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
Identifying the contact
| Parameter | Type | Required | Description |
|---|---|---|---|
contact_id | string | Conditional | Arsel contact UUID. Mutually exclusive with email/phone_number. |
email | string | Conditional | Contact email. Matched case-insensitively; authoritative when both email and phone_number are given. |
phone_number | string | Conditional | Contact phone in E.164 format (e.g. +966501234567). |
Provide contact_id on its own, or one or both of email and phone_number. Combining contact_id with the others, or providing no identifier, returns 400. This is the same contact reference shape Send Event accepts.
Notification content
| Parameter | Type | Required | Description |
|---|---|---|---|
title | string | Yes | Notification title. 1–200 characters. |
body | string | Yes | Notification body. 1–1000 characters. |
image_url | string | No | Large image. HTTPS only. |
deep_link | string | No | Opened when the notification is tapped. Max 2048 characters. |
android_channel_id | string | No | Android notification channel id. Max 128 characters. |
category | string | No | Analytics label, e.g. otp or order_update. Max 64 characters. |
ttl_seconds | integer | No | How long delivery may be retried, 60–2419200 (28 days, FCM's maximum). |
priority | string | No | high or normal. |
action_buttons | object[] | No | Up to 3 tappable buttons. |
data_payload | object | No | Flat string→string map delivered alongside the notification. |
data_payload must be a flat map of strings to strings. Keys beginning arsel_, google, or gcm, and FCM's own reserved keys, are rejected — Arsel uses that namespace for the metadata that makes delivery and engagement tracking work.
{
"email": "john.doe@example.com",
"title": "Your order shipped",
"body": "Order A-1023 is on its way — arriving Thursday.",
"deep_link": "myapp://orders/A-1023",
"category": "order_update",
"ttl_seconds": 86400,
"data_payload": {
"order_id": "A-1023"
}
}
Response
The notification is accepted for asynchronous delivery. Every ACTIVE device the contact has registered receives it.
{
"id": "01957e3a-4b5c-7d8e-9f0a-1b2c3d4e5f6a"
}
| Field | Type | Description |
|---|---|---|
id | string | Message identifier (UUIDv7). Use it to track delivery in push analytics. |
A 202 means Arsel accepted the request, not that a device displayed the notification. Delivery and engagement are reported afterwards by the SDK on the device.
A contact who is reachable but currently has no active device is a successful skip, not an error — you still get a 202, and the per-device outcome appears in push analytics.
Idempotency
Sending the same notification twice — for example because a network timeout made you retry — could send the notification twice to the same device. To make a retry safe, send an Idempotency-Key header whose value is unique to that notification (for example order.shipped/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.shipped/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/push/send" \
-H "Authorization: Bearer be_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order.shipped/A-1023" \
-d '{
"email": "john.doe@example.com",
"title": "Your order shipped",
"body": "Order A-1023 is on its way.",
"deep_link": "myapp://orders/A-1023"
}'
const response = await fetch("https://api.arsel.sa/v1/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer be_your_api_key",
},
body: JSON.stringify({
email: "john.doe@example.com",
title: "Your order shipped",
body: "Order A-1023 is on its way.",
deep_link: "myapp://orders/A-1023",
}),
});
const result = await response.json();
console.log(result.id, result.status);
import requests
response = requests.post(
"https://api.arsel.sa/v1/push/send",
headers={
"Content-Type": "application/json",
"Authorization": "Bearer be_your_api_key",
},
json={
"email": "john.doe@example.com",
"title": "Your order shipped",
"body": "Order A-1023 is on its way.",
"deep_link": "myapp://orders/A-1023",
},
)
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
{
email = "john.doe@example.com",
title = "Your order shipped",
body = "Order A-1023 is on its way.",
deep_link = "myapp://orders/A-1023"
};
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/push/send", content);
Console.WriteLine(await response.Content.ReadAsStringAsync());
<?php
$ch = curl_init("https://api.arsel.sa/v1/push/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([
"email" => "john.doe@example.com",
"title" => "Your order shipped",
"body" => "Order A-1023 is on its way.",
"deep_link" => "myapp://orders/A-1023"
]));
$response = curl_exec($ch);
echo $response;
curl_close($ch);
Error Responses
- 400 Contact
- 404 Contact
- 403 Unreachable
- 429 Quota
- 422 Validation
- 401 Unauthorized
Provide contact_id on its own, or one/both of email and phone_number:
{
"status_code": 400,
"name": "bad_request",
"message": "Provide at least one of contact_id, email, or phone_number."
}
Combining contact_id with the others returns:
{
"status_code": 400,
"name": "bad_request",
"message": "Provide contact_id on its own, or one/both of email and phone_number — not contact_id together with them."
}
Deliberately identical whichever identifier you supplied, so the route cannot be used to test whether an address exists in your organization:
{
"status_code": 404,
"name": "not_found",
"message": "No contact in this organization matches the supplied reference."
}
The contact opted out of push, or every device they registered is no longer deliverable. Push consent is checked on every send, so this applies whatever the message content:
{
"status_code": 403,
"name": "forbidden",
"message": "This contact is not reachable on push — they opted out, or their devices are no longer deliverable. Push has no transactional exemption; use email or SMS for essential service messages."
}
{
"status_code": 429,
"name": "quota_exceeded",
"message": "Monthly push quota exhausted (50000/50000 used)."
}
Returned when the body fails validation — a title over 200 characters, a non-HTTPS image_url, more than 3 action_buttons, or a data_payload key using a reserved prefix (arsel_, google, gcm).
{
"status_code": 422,
"name": "validation_error",
"message": "Validation failed"
}
{
"status_code": 401,
"name": "unauthorized",
"message": "Invalid or missing API key"
}