Getting Started
Ekas is the compliance layer for A2P messaging. One API covers all three channels — 10DLC (brand and campaign registration through The Campaign Registry), RCS (agent verification for Google RBM and carrier launch), and TFN (US and Canada toll-free verification) — with the same engine behind pre-submission checks, submissions, and the whole post-approval lifecycle.
The idea behind the API is simple: return the fix, not the error. A compliance problem is never an HTTP error. It comes back as a successful response containing findings, and every finding carries the exact language that resolves it — ready to paste into your form, your database, or your customer's screen.
In a hurry?The five calls below take about ten minutes end to end, entirely in test mode. Nothing you do here touches a carrier or costs you a registration.
What you'll do
- Authenticate and confirm your account
- Run a compliance check and read the findings
- Apply the suggested fix and watch the check pass
- Store the brand and campaign as reusable resources
- Connect your provider and submit a real registration
- Track it to approval with webhooks
Before you begin
You need an Ekas account and an API key. Every account gets two kinds of key:
| Key prefix | What it does |
|---|---|
ek_test_... | Runs against an isolated test data plane. No submissions reach TCR, Google, or any carrier. Registrations advance through their pipeline automatically (about 30 seconds per stage), and nothing counts against your allowance. |
ek_live_... | Real registrations, real provider accounts, real carrier review. |
Build your entire integration with the test key first. Every example on this page works with it.
Your secret is shown onceAPI key secrets appear only in the response that creates them. Store the secret when you create the key; afterward you can only see a masked prefix like
ek_live_Ab3…4f2a. If you lose one, roll the key — the old secret keeps working for 24 hours so you can deploy without downtime.
Step 1 — Authenticate
Every request carries your key in the Authorization header. The base URL is https://app.ekas.io/v1.
curl https://app.ekas.io/v1/me \
-H "Authorization: Bearer ek_test_..."{
"id": "acct_01J8ZDF3K2",
"object": "account",
"name": "Northwind Communications",
"mode": "test",
"plan": { "name": "Standard", "allowance": 50 },
"usage": { "used": 12, "remaining": 38, "period_end": "2026-08-01T00:00:00Z" }
}If this returns your account, you're ready. A 401 means the key is wrong or malformed; a 403 means the key is missing a scope, and error.param names exactly which one.
Step 2 — Run your first compliance check
Checks are free and unlimited. They never consume your registration allowance, so run them as often as you like — on every keystroke in a form, on every draft, before every submission.
Send whatever you have. You do not need a complete brand or campaign; missing information comes back as findings rather than validation errors.
curl https://app.ekas.io/v1/checks \
-H "Authorization: Bearer ek_test_..." \
-H "Content-Type: application/json" \
-d '{
"channel": "10dlc",
"brand": {
"ein": "84-1234567",
"website": "https://northwind.example"
},
"campaign": {
"use_case": "customer_care",
"description": "Order status and delivery updates",
"sample_messages": ["Sign up for updates."]
},
"options": { "wait": true }
}'{
"id": "chk_01J8ZCJH8M",
"object": "check",
"channel": "10dlc",
"status": "changes_required",
"findings": [
{
"field": "campaign.sample_messages[0]",
"code": "missing_opt_out_disclosure",
"severity": "blocker",
"category": "disclosures",
"issue": "Missing opt-out instruction and rate disclosure",
"suggested_language": "Sign up for order updates from Northwind. Msg & data rates may apply. Msg frequency varies. Reply STOP to opt out, HELP for help.",
"doc_url": "https://docs.ekas.io/findings/missing_opt_out_disclosure"
}
],
"completed_at": "2026-07-20T18:04:11Z",
"mode": "test"
}Anatomy of a finding
This is the part worth building your UI around.
| Field | What to do with it |
|---|---|
field | A JSON path into the payload you sent — campaign.sample_messages[0]. Map it straight to the input that needs attention. |
severity | blocker stops a submission. warning and info do not — a check can pass while still carrying them. |
suggested_language | Compliant replacement text. Render it as a one-click fix; don't make your user write it. |
code | A stable identifier. Use it for analytics, custom copy, or your own help content. |
category | Which area failed: shaft, opt_in, opt_out, disclosures, website, asset_spec, brand_identity, volume, content. |
Fast checks and slow checks
By default a check crawls the brand's website, privacy policy, and terms, which takes a few seconds. You have three ways to handle that:
- Wait inline — pass
"options": { "wait": true }as above. Simplest, and fine for interactive forms. - Poll — omit
wait, get backstatus: "processing", then pollGET /checks/{id}. - Subscribe — listen for the
check.completedwebhook and never block.
To skip the crawl entirely and validate only the payload, pass "options": { "include_website_review": false }. That path returns almost immediately.
The/compliance/checkaliasIf you've seen
POST /v1/compliance/checkin our marketing material, it still works and always will. It's a thin alias that waits inline and returns a compact{ status, findings }body, and it accepts use cases in either case (CUSTOMER_CAREorcustomer_care). New integrations should usePOST /v1/checks, which returns the full check resource and supports all three channels.
Step 3 — Apply the fix and re-check
Take suggested_language, put it back in the payload, run the check again.
curl https://app.ekas.io/v1/checks \
-H "Authorization: Bearer ek_test_..." \
-H "Content-Type: application/json" \
-d '{
"channel": "10dlc",
"brand": {
"ein": "84-1234567",
"website": "https://northwind.example"
},
"campaign": {
"use_case": "customer_care",
"description": "Order status and delivery updates",
"sample_messages": [
"Sign up for order updates from Northwind. Msg & data rates may apply. Msg frequency varies. Reply STOP to opt out, HELP for help."
]
},
"options": { "wait": true }
}'{
"id": "chk_01J8ZCKR4T",
"object": "check",
"status": "passed",
"findings": [],
"mode": "test"
}That loop — check, fix, re-check — is the whole product. Everything below is about getting a passing payload to a carrier.
Step 4 — Store your brand and campaign
Inline payloads are perfect for validating drafts. For anything you'll submit, register, or track over time, store it as a resource and refer to it by id.
curl https://app.ekas.io/v1/brands \
-H "Authorization: Bearer ek_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: brand-northwind-001" \
-d '{
"legal_name": "Northwind Communications LLC",
"display_name": "Northwind",
"ein": "84-1234567",
"entity_type": "private_profit",
"vertical": "retail",
"website": "https://northwind.example",
"email": "[email protected]",
"phone": "+14155550142",
"address": {
"street": "500 Market St",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country": "US"
},
"privacy_policy_url": "https://northwind.example/privacy",
"terms_url": "https://northwind.example/terms"
}'The response returns brand_01J8ZC4N9Q. Note that ein never comes back — reads return ein_last4 only. Now create the campaign against it:
curl https://app.ekas.io/v1/campaigns \
-H "Authorization: Bearer ek_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: campaign-northwind-care-001" \
-d '{
"brand_id": "brand_01J8ZC4N9Q",
"use_case": "customer_care",
"description": "Order status and delivery updates",
"message_flow": "Customers opt in at checkout by checking an unchecked SMS consent box next to the order summary.",
"sample_messages": [
"Sign up for order updates from Northwind. Msg & data rates may apply. Msg frequency varies. Reply STOP to opt out, HELP for help."
],
"opt_in": {
"types": ["website"],
"description": "Unchecked consent checkbox on the checkout page",
"confirmation_message": "You are subscribed to Northwind order updates. Reply STOP to cancel, HELP for help."
},
"opt_out": { "keywords": ["STOP"], "message": "You are unsubscribed from Northwind alerts. No more messages will be sent." },
"help": { "keywords": ["HELP"], "message": "Northwind support: [email protected] or +1 415 555 0142." }
}'From here you can check a stored subject by reference instead of resending the payload:
{ "channel": "10dlc", "brand_id": "brand_01J8ZC4N9Q", "campaign_id": "cmp_01J8ZC7T2B" }Pass the reference or the inline payload for a given subject, never both.
Every write can be retried safelySend an
Idempotency-Keyheader — any unique string — on any POST. For 24 hours, retries with the same key return the original response instead of creating a second resource. Network timeouts stop being scary.
Step 5 — Connect your provider
Ekas can submit on your behalf through the CPaaS account you already have. GET /providers lists what each one needs.
curl https://app.ekas.io/v1/provider-connections \
-H "Authorization: Bearer ek_test_..." \
-H "Content-Type: application/json" \
-d '{
"provider": "twilio",
"name": "Twilio — production",
"credentials": {
"account_sid": "AC00000000000000000000000000000000",
"auth_token": "test_magic_token"
}
}'Credentials are write-only. They're verified live before being stored, encrypted at rest, and reads return only a masked credential_fingerprint so you can tell which account is connected. Confirm the connection any time with POST /provider-connections/{id}/test.
Don't want Ekas touching your provider account? Skip this step and use package delivery instead — you'll get the compliant payload back and file it yourself.
Step 6 — Submit the registration
A registration is the billable unit: one brand, campaign, agent, or verification submission on any channel, drawn from your monthly pooled allowance.
curl https://app.ekas.io/v1/registrations \
-H "Authorization: Bearer ek_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reg-cmp-8842-attempt-1" \
-d '{
"type": "10dlc_campaign",
"campaign_id": "cmp_01J8ZC7T2B",
"provider_connection_id": "prov_01J8Z2X4N7",
"delivery": { "mode": "provider" }
}'Every registration runs a compliance check before anything leaves Ekas. If it finds blockers, the registration stops at changes_required and no provider ever sees it — read the linked check_id for the findings, fix the subject, and call POST /registrations/{id}/resubmit. A submission that goes out is a submission that passed.
The three delivery modes
| Mode | What happens |
|---|---|
provider | Ekas submits through your connected CPaaS account and tracks it to a decision. |
package | Ekas hands you the compliant payload from GET /registrations/{id}/package in generic, TCR, or provider-specific format. File it yourself, then report back with POST /registrations/{id}/external-status so analytics and the guarantee keep working. |
push | Ekas POSTs the package to your own endpoint, signed exactly like a webhook. |
Step 7 — Track it to approval
Create an endpoint and pick your events:
curl https://app.ekas.io/v1/webhook-endpoints \
-H "Authorization: Bearer ek_test_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.northwind.example/hooks/ekas",
"enabled_events": [
"check.completed",
"registration.changes_required",
"registration.approved",
"registration.rejected",
"registration.carrier_updated"
]
}'The response contains a signing secret (whsec_...), shown once. Verify the Ekas-Signature header on every delivery:
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const parts = header.split(",").map(p => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return parts
.filter(([k]) => k === "v1")
.some(([, v]) =>
v.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v)));
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
if t is None or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")
Collect everyv1, not just oneDuring a secret rotation the header carries two
v1=entries for 24 hours — one per secret — so you can roll without dropping deliveries. Parsing the header into a plain map keeps only the last one and will reject valid traffic. Both snippets above handle this correctly.
Failed deliveries retry at 1m, 5m, 30m, 2h, 6h, then every 12 hours for up to 72 hours, after which the endpoint is disabled and you get a webhook_endpoint.disabled event. Delivery order isn't guaranteed — treat each event as a notification and re-fetch the resource, or process data.object idempotently keyed on the event id. Anything you miss can be replayed from GET /events.
Prefer polling? GET /registrations supports updated_at_gte for delta sync, so you can ask for only what changed since your last pass.
Reading a registration's progress
status is deliberately channel-independent, so one state machine covers all three channels:
draft → validating → changes_required ⇄ ready → submitting → submitted → pending → approved or rejected
While status is pending, pipeline.stage tells you where it actually sits — tcr_review, dca_review, carrier_review for 10DLC; brand_verification, agent_review, carrier_launch for RCS. Fan-out detail lives alongside it: carrier_statuses per MNO, launch_statuses per RCS carrier and region, and number_statuses per toll-free number (toll-free verification is decided per number, not per submission).
Already registered somewhere else?
Don't re-register — you'd pay twice and reset weeks of carrier review. Point Ekas at what you already have:
curl https://app.ekas.io/v1/provider-connections/prov_01J8Z2X4N7/sync \
-H "Authorization: Bearer ek_test_..." \
-X POSTThat returns everything importable in the connected account, sorted into importable, linked, and conflicting. Feed the importable ones through POST /registrations with an import block, or send them as a batch. Imported registrations arrive as approved, cost nothing against your allowance, and from then on emit the same lifecycle events and renewal warnings as anything you registered through us.
What actually costs a registration
| Action | Allowance |
|---|---|
| Compliance checks | Free, unlimited |
| Imports | Free |
| Anything in test mode | Free |
| A registration submission | 1 |
| A vetting order | 1 |
| A refile after a rejection Ekas cleared | Credited back |
That last row is the rejection guarantee. If a registration passed our check, went out unmodified, and still got rejected, we refund the provider fee, credit the registration back to your allowance, and diagnose and refile it automatically. You'll see registration.guarantee_applied. Watch GET /usage and the usage.threshold_reached event (80% and 100%) to stay ahead of the pool.
Going live
- Swap
ek_test_forek_live_and give production keys the narrowest scopes that work - Handle
changes_requiredin your UI by renderingsuggested_languageas an applyable fix - Send an
Idempotency-Keyon every POST - Verify webhook signatures with the multi-
v1logic above, and return 2xx within 10 seconds - Store Ekas ids (
brand_,cmp_,reg_) next to your own records so you can reconcile later - Subscribe to
registration.approved,registration.rejected,registration.carrier_updated, andregistration.renewal_upcoming - If you serve end customers, create a subaccount per customer so usage, events, and keys stay separated
- Set a reminder on
registration.renewal_upcoming— 10DLC campaigns expire if TCR renewal lapses, and messaging stops silently
Where to go next
| API Reference | Every endpoint, with a live request explorer |
| Webhook Events | All 52 events and the exact payload each delivers |
| Registrations | The full submission lifecycle, per channel |
| Provider Connections | Twilio, Telnyx, Bandwidth, Sinch, Infobip, Plivo, Bird, and custom endpoints |
Questions, or a rejection you don't understand? Email [email protected] with the Request-Id from the response header — it's on every response we send.
Updated 1 day ago