API reference
All 2X products share one API. This page is a short reference for developers; the full schema lives in the console and the OpenAPI document.
Status: email sending and webhooks are live. SMS operator connection is launching. OTP endpoints are launching; field names may change.
Conventions
- Base URL:
https://api.reach2x.com - Requests and responses are JSON (UTF-8). Times are RFC 3339 in UTC.
- Phone numbers are E.164 (
+905321234567) and masked in responses (+90 5** *** ** 67). - Message bodies are kept for your retention period and never returned by the API. For the otp category the body is never stored.
Authentication
Add your project API key as a Bearer token to every request:
Authorization: Bearer rx_canli_… (live) · rx_test_… (test project; never delivered to a real recipient)
Keys are created in the console. The secret is shown once and only its hash is stored. A key belongs to one project and may carry allowed senders, an IP allow list and an expiry.
| Scope | Allows |
|---|---|
sms.send | Send SMS and cancel scheduled SMS |
sms.read | Read SMS and the message list |
email.send | Send email |
email.read | Read email |
otp.send | Send verification codes (launching) |
otp.verify | Check verification codes (launching) |
webhook.manage | Manage webhook endpoints |
balance.read | Read quota and usage |
Idempotency
Every call that sends something requires an Idempotency-Key header: 8–128 characters from A-Z a-z 0-9 _ - : . (a UUID works). Keys are kept for 24 hours per organization + credential.
- Same key + same body → the original response, with
Idempotent-Replay: trueheader. - Same key + different body →
422 idempotency_key_mismatch. - While the first is still running →
409 idempotency_in_progress.
Send email POST /v1/emails
Scope email.send. Creates one message per recipient and returns 202 after storing them durably. The from domain must be verified; messages are DKIM-signed for it. Attachments are not supported yet.
| Field | Type | Notes |
|---|---|---|
from | string | {email, name} | Address on your verified sending domain. Required. |
to | string | string[] | One address or up to 50. Required. |
subject | string | 1–998 characters. Required. |
text / html | string | At least one required; each up to 500,000 characters. |
category | informational | otp | commercial | Required. commercial is not available yet. |
reply_to | string | Reply-to address. |
send_at | date-time | Up to 30 days ahead; empty means now. |
valid_for_minutes | integer | 1–4320, default 2880. Then expired. |
tags | object | Up to 10 key/value pairs; no personal data. |
curl https://api.reach2x.com/v1/emails \
-H "Authorization: Bearer $REACH2X_API_KEY" \
-H "Idempotency-Key: rapor-2026-11-0001" \
-H "Content-Type: application/json" \
-d '{
"from": { "email": "[email protected]", "name": "Example" },
"to": "[email protected]",
"subject": "Aylık raporunuz hazır",
"text": "Raporunuz hazır: https://example.com/r/Ab12",
"category": "informational"
}' {
"batch_id": "7d1c2e60-4a8b-4f0e-9b1a-2f6c1d9e3a10",
"messages": [{
"id": "c0a8f1e2-5b3d-4e7f-8a9b-1c2d3e4f5a6b",
"object": "email",
"status": "queued",
"category": "informational",
"from": "[email protected]",
"to": "a***@example.org"
}]
} Read: GET /v1/emails, GET /v1/emails/{id} (with event timeline; scope email.read).
Send SMS POST /v1/sms
The TXT2X operator connection is launching — live soon. You can try the same call today with a test key.
Scope sms.send. category decides the legal handling: informational (transactional), otp (body never stored), commercial (İYS: recipient type and brand code required; recipients without consent fail with recipient_no_consent).
Encoding is automatic: gsm7 160/153, gsm7_tr 155/149, ucs2 70/67 characters per segment.
| Field | Type | Notes |
|---|---|---|
to | string | string[] | E.164 number or up to 100 distinct numbers. Required. |
body | string | At most 7 segments after encoding. Required. |
category | informational | otp | commercial | Required. Decides the legal handling. |
sender | string | Approved sender name, 3–11 chars. Defaults to the project sender. |
send_at | date-time | Up to 30 days ahead. |
valid_for_minutes | integer | 1–2880, default 1440. |
commercial.recipient_type | individual | merchant | Required for commercial (İYS recipient type). |
tags | object | Up to 10 key/value pairs. |
curl https://api.reach2x.com/v1/sms \
-H "Authorization: Bearer $REACH2X_API_KEY" \
-H "Idempotency-Key: randevu-48213" \
-H "Content-Type: application/json" \
-d '{
"to": "+905321234567",
"body": "Randevunuz 18 Kasım 14:30. Değişiklik: https://example.com/r/Ab12",
"category": "informational",
"sender": "EXAMPLE"
}' {
"batch_id": "3f9a2b7c-1d4e-4a5b-8c6d-7e8f9a0b1c2d",
"messages": [{
"id": "8b7a6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
"object": "sms",
"status": "queued",
"to": "+90 5** *** ** 67",
"sender": "EXAMPLE",
"segments": 1,
"encoding": "gsm7_tr"
}]
} Other endpoints: GET /v1/sms/{id}, POST /v1/sms/{id}/cancel (scheduled only), GET /v1/balance (scope balance.read).
Verification codes (OTP)
The email channel is live; the SMS channel opens with the operator connection. Every POST needs an Idempotency-Key; one code per recipient per 60 s (at most 5 per hour), the code locks after 5 wrong attempts and is single-use.
POST /v1/otp—{ channel: "sms" | "email", to, from?, subject?, sender?, length?, ttl_seconds?, template? }→ 201{ id, status, attempts_left, expires_at, … }. Scopeotp.send.POST /v1/otp/{id}/verify—{ code }→{ verified, attempts_left, status }; errorsotp_expired410,otp_locked409,otp_already_verified409,otp_rate_limited429. Scopeotp.verify.
curl https://api.reach2x.com/v1/otp \
-H "Authorization: Bearer $REACH2X_API_KEY" \
-H "Idempotency-Key: giris-7f3a91c2" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"to": "[email protected]",
"from": "[email protected]",
"length": 6,
"ttl_seconds": 300
}' {
"id": "5e4d3c2b-1a09-4f8e-8d7c-6b5a49382716",
"object": "otp",
"channel": "email",
"status": "pending",
"to": "a***@ornek.com",
"attempts_left": 5,
"expires_at": "2026-11-14T09:35:00Z"
} curl https://api.reach2x.com/v1/otp/5e4d3c2b-1a09-4f8e-8d7c-6b5a49382716/verify \
-H "Authorization: Bearer $REACH2X_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "482913" }' {
"verified": true,
"attempts_left": 4,
"status": "verified"
} Message statuses
scheduled → queued → sending → sent → delivered | failed | expired; canceled only from scheduled.
| Status | Meaning |
|---|---|
scheduled | Waiting for send_at; only this status can be canceled. |
queued | Queued; also returned to on a temporary network error. |
sending | Being handed to the network (transient). |
sent | Accepted by the network; waiting for a handset receipt. |
delivered | SMS: handset receipt. Email: recipient server accepted (SMTP 250). |
failed | Not delivered; error.code gives the reason. |
expired | Validity ended before delivery. |
canceled | Scheduled message canceled. |
Webhook
Manage endpoints with GET/POST /v1/webhooks, DELETE /v1/webhooks/{id}, POST /v1/webhooks/{id}/rotate-secret (scope webhook.manage). Only https URLs resolving to a public address are accepted. The signing secret is shown once, in the create response.
Events: sms.queued, sms.sent, sms.delivered, sms.failed, sms.expired, email.queued, email.delivered, email.failed, email.expired, otp.verified, otp.failed. Use "*" for all.
curl https://api.reach2x.com/v1/webhooks \
-H "Authorization: Bearer $REACH2X_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/2x",
"events": ["sms.delivered", "sms.failed", "email.delivered", "email.failed"]
}' {
"webhook": {
"id": "1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d",
"url": "https://example.com/hooks/2x",
"status": "active",
"secret_prefix": "whsec_9fK2"
},
"secret": "whsec_… (shown only once)"
} Verify every request
- Read header
x-imza:t=<unix>,v1=<hex>. HMAC-SHA256(secret, "<t>.<ham gövde>")(lowercase hex).- Compare with v1 in constant time; reject if |now − t| > 300 s.
x-olay-iddeduplicates (the same event may arrive more than once).
import crypto from 'node:crypto';
// header "x-imza": t=<unix seconds>,v1=<hex>
export function verify(rawBody, header, secret) {
const p = Object.fromEntries(header.split(',').map((x) => x.split('=')));
const expected = crypto
.createHmac('sha256', secret)
.update(`${p.t}.${rawBody}`)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(p.t)) <= 300;
return (
fresh &&
expected.length === p.v1?.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1))
);
} Without a 2xx within 10 s: retries after 1 min, 5 min, 30 min, 2 h, 6 h, 24 h. After 20 consecutive failures the endpoint is disabled. Ordering is not guaranteed; use veri.status and veri.updated_at.
Envelope fields: id, olay (event type), zaman, kiraci (organization), veri (message summary).
Error codes
Errors are RFC 9457 problem+json. code is stable; title and detail are for humans and may change.
{
"type": "https://reach2x.com/docs/errors#sender_not_allowed",
"title": "Sender not allowed",
"status": 422,
"code": "sender_not_allowed",
"request_id": "req_01JD3Q7Z8K2M"
} | code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Malformed JSON, unknown field or wrong type. |
idempotency_key_required | 400 | Idempotency-Key missing on a send. |
unauthorized | 401 | Credential missing, invalid, expired or revoked. |
forbidden | 403 | Missing scope, sender or IP permission. |
not_found | 404 | Not in your organization (other tenants’ ids too). |
idempotency_in_progress | 409 | Same-key request still in progress. |
not_cancelable | 409 | Message is no longer scheduled. |
payload_too_large | 413 | Body larger than 1 MB. |
idempotency_key_mismatch | 422 | Same key reused with a different body. |
validation_failed | 422 | Semantically invalid input; errors[] lists fields. |
invalid_phone_number | 422 | Recipient is not valid E.164. |
body_too_long | 422 | Body needs more than 7 segments. |
sender_not_allowed | 422 | Sender not approved or not allowed for this key. |
iys_recipient_type_required | 422 | commercial without recipient_type. |
iys_brand_code_missing | 422 | commercial but no İYS brand code. |
invalid_send_at | 422 | send_at in the past or beyond 30 days. |
sender_domain_not_verified | 422 | from domain not verified (email). |
commercial_email_not_supported | 422 | commercial email not available yet. |
quota_exceeded | 429 | Monthly segment quota reached (informational/OTP continue). |
rate_limited | 429 | Too many requests; honor Retry-After. |
internal_error | 500 | Unexpected error; report with request_id. |
service_unavailable | 503 | Temporary; safe to retry with the same Idempotency-Key. |
Delivery failures (error.code — SMS): undeliverable, unknown_number, rejected_by_carrier, content_rejected, duplicate_message, recipient_no_consent, commercial_not_permitted, recipient_blocked, destination_not_supported, sender_rejected, expired, delivery_failed, delivery_unavailable.
Email: recipient_rejected, domain_not_found, message_rejected, delivery_unavailable, expired, delivery_failed.
Reading and pagination
Lists are newest first and cursor based: limit (1–100, default 50) + cursor → { data, next_cursor, has_more }. Filters: status, category, batch_id, created_after, created_before.