DEVELOPERS

API reference.

One REST API for transactional email, mailing lists, currency, push messaging, and social automation. Authenticate with a bearer key and you're sending in minutes.

Introduction

The startupaid API is organized around REST. It accepts JSON request bodies, returns JSON responses, and uses standard HTTP status codes and verbs. The base URL is https://api.startupaid.org.

Every endpoint under /v1 uses an API key. Sending domains, email templates, event webhooks, deep links, and social channels are set up once in your dashboard — then used from the API by reference (address, slug, channel id) or, for webhooks, by receiving events.

Authentication

Every /v1 request requires an API key, passed as a bearer token or the X-API-Key header. Keys are hashed at rest and can be revoked at any time from your dashboard.

Authorization: Bearer sa_live_your_key_here

Endpoints

GroupMethodPathAuthDescription
AppsGET/v1/appsAPI keyList your apps (shared across Push & OTP)
AppsPOST/v1/appsAPI keyCreate an app; returns its unique appId
AppsPATCH/v1/apps/:appIdAPI keyRename an app
AppsDELETE/v1/apps/:appIdAPI keyDelete an app and its module resources
EmailPOST/v1/sendAPI keyQueue and send a transactional email
EmailGET/v1/messages/:idAPI keyFetch a message's delivery status
Mailing listsGET/v1/audiencesAPI keyList your audiences (mailing lists)
Mailing listsPOST/v1/audiencesAPI keyCreate an audience
Mailing listsGET/v1/audiences/:idAPI keyFetch a single audience
Mailing listsPATCH/v1/audiences/:idAPI keyRename / edit an audience
Mailing listsDELETE/v1/audiences/:idAPI keyDelete an audience and its contacts
ContactsGET/v1/audiences/:id/contactsAPI keyList contacts (search + paging)
ContactsPOST/v1/audiences/:id/contactsAPI keyAdd a contact
ContactsPOST/v1/audiences/:id/contacts/importAPI keyBulk import contacts
ContactsPATCH/v1/contacts/:idAPI keyUpdate a contact / subscription
ContactsDELETE/v1/contacts/:idAPI keyRemove a contact
CurrencyGET/v1/convertAPI keyConvert an amount between any two currencies
CurrencyGET/v1/rates/:baseAPI keyCross-rates for a base currency
CurrencyGET/v1/currenciesAPI keyList supported fiat & crypto currencies
CurrencyGET/v1/currency/statusAPI keyRate freshness by asset class
PushPOST/v1/push/apps/:appId/devicesAPI keyRegister an end-user's device token
PushGET/v1/push/apps/:appId/devicesAPI keyList a user's registered devices
PushDELETE/v1/push/apps/:appId/devicesAPI keyRemove a device token
PushPOST/v1/push/apps/:appId/sendAPI keySend a notification (iOS, Android & web)
PushGET/v1/push/apps/:appId/messages/:idAPI keyPer-device delivery breakdown
WhatsApp OTPPOST/v1/otp/sendAPI keySend a one-time passcode over WhatsApp
WhatsApp OTPPOST/v1/otp/verifyAPI keyVerify a code the recipient submitted
SocialPOST/v1/social/scheduleAPI keySchedule or publish a post to connected channels
SocialGET/v1/social/postsAPI keyList your scheduled & published posts
SystemGET/healthPublicLiveness probe

Apps

Apps & app IDs

An app is the account-wide unit you create once and target from any app-centric product — Push and WhatsApp OTP today (Email is the exception: it targets verified domains, not apps). Create apps in the dashboard or via the API.

Every app has a unique id like app_9f3k2x8a…. Always reference an app by its id, never its name — names are display-only and can repeat across accounts. For OTP the app's name is also the brand shown in the message (“Your Acmeverification code is 123456”); for Push it labels the app in your dashboard.

infoThe Free plan includes one app. Paid plans raise the limit (and Scale is unlimited) — upgrade or contact us to add more.
# Create an app — the returned id (app_…) is what you target everywhere.
curl -X POST https://api.startupaid.org/v1/apps \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme" }'
# => { "id": "app_9f3k2x8a1b2c3d4e5f6a", "name": "Acme", "createdAt": "…" }

# List your apps
curl https://api.startupaid.org/v1/apps \
  -H "Authorization: Bearer sa_live_xxx"

Email

Sending domains

You send from your own domain. Add and verify it once under Domains in the dashboard — we generate the DKIM records, you publish them at your DNS host, and hit verify. After that, any from address on that domain is accepted. Sends from an unverified domain return 403.

Send an email

POST /v1/send with a from (on a verified domain), to, subject, and an HTML body. Delivery is asynchronous: you get 202 Accepted immediately with a queued message, and we retry with backoff behind the scenes.

curl -X POST https://api.startupaid.org/v1/send \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@yourdomain.com",
    "to": "user@example.com",
    "subject": "Welcome aboard",
    "body": "<h1>Hi there 👋</h1><p>Thanks for signing up.</p>"
  }'

# => 202 Accepted
# { "id": "…", "status": "queued", "to": "user@example.com" }

Send via SMTP

Prefer SMTP, or using software that only speaks it? Point your app at our submission relay — no code changes. Authenticate with any of your API keys (username apikey, password your sa_live_… key). Messages funnel into the exact same pipeline as /v1/send, with the same domain rules, quotas, DKIM, and delivery events.

Host:        smtp.startupaid.org
Port:        587
Encryption:  STARTTLS
Username:    apikey
Password:    sa_live_your_key   (any API key)
From:        an address on a verified domain

AUTH is only offered after STARTTLS, so your key is never sent in the clear. Your From must be on a verified domain; unverified senders fall back to the daily-capped sandbox.

import nodemailer from "nodemailer";

const transport = nodemailer.createTransport({
  host: "smtp.startupaid.org",
  port: 587,
  secure: false,                 // STARTTLS is negotiated on 587
  auth: { user: "apikey", pass: "sa_live_your_key" },
});

await transport.sendMail({
  from: "hello@yourdomain.com",  // a verified domain
  to: "user@example.com",
  subject: "Hello from SMTP",
  html: "<h1>Hi 👋</h1>",
});

Attachments

Add an attachments array to any /v1/send request. Each attachment is a base64-encoded file with a filename; contentType is optional (we infer it from the extension). Up to 10 files and 20 MB total per message.

POST /v1/send
{
  "from": "hello@acme.com",
  "to": "user@example.com",
  "subject": "Your invoice",
  "body": "<p>Invoice attached.</p>",
  "attachments": [
    {
      "filename": "invoice.pdf",
      "contentType": "application/pdf",
      "content": "JVBERi0xLjQ…"          // base64
    }
  ]
}

Send with a template

Create reusable templates in the dashboard (with {{variable}} placeholders), then send by template slug and pass variables. Omit subject and body — they come from the template. Variable values are HTML-escaped for you.

curl -X POST https://api.startupaid.org/v1/send \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@yourdomain.com",
    "to": "user@example.com",
    "template": "welcome_onboarding",
    "variables": { "name": "Alex", "plan": "Founder" }
  }'

Idempotency

Send an Idempotency-Key header to safely retry a request without sending twice. A repeated key returns the original queued message (with an Idempotent-Replayed: true response header) instead of sending again.

curl -X POST https://api.startupaid.org/v1/send \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Idempotency-Key: order-4192-receipt" \
  -H "Content-Type: application/json" \
  -d '{ "from": "hello@yourdomain.com", "to": "user@example.com",
        "subject": "Receipt", "body": "<p>Thanks!</p>" }'

Message status

Poll GET /v1/messages/:id using the id returned by a send. Engagement events (delivered, opened, clicked, bounced, complained) are also visible under Logs in the dashboard.

curl https://api.startupaid.org/v1/messages/MESSAGE_ID \
  -H "Authorization: Bearer sa_live_xxx"

# => {
#   "id": "…", "to": "user@example.com",
#   "status": "delivered",   // queued | sending | sent | delivered | failed
#   "attempts": 1, "createdAt": "…"
# }

Mailing lists

An audience is a mailing list — a named collection of contacts. Manage them from the API or the dashboard; both share the same data. An email address is unique per audience.

curl -X POST https://api.startupaid.org/v1/audiences \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Newsletter", "description": "Weekly product updates" }'

# => { "id": "AUDIENCE_ID", "name": "Newsletter", "contactCount": 0, ... }

Contacts & import

Add contacts one at a time or bulk-import them. Each contact takes an email, optional firstName/lastName, and a free-form data JSON object for merge attributes. Import de-dupes and skips invalid rows rather than failing the batch.

curl -X POST https://api.startupaid.org/v1/audiences/AUDIENCE_ID/contacts \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "firstName": "Ada",
    "lastName": "Lovelace",
    "data": "{\"plan\": \"pro\"}"
  }'
# => { "id": "…", "email": "ada@example.com", "subscribed": true, ... }

Unsubscribe

Every contact has an opaque unsubscribe token and a hosted one-click page — no login required for the recipient. Link to it from your list emails, or flip a contact's subscribed state via the API.

# Flip a contact's subscription state
curl -X PATCH https://api.startupaid.org/v1/contacts/CONTACT_ID \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "subscribed": false }'

Currency

Convert currency

GET /v1/convert converts an amount between any two supported currencies — fiat or crypto. Rates come from our own engine (institutional reference rates for fiat, a live feed for crypto), and every response includes rateAsOf so you know exactly how fresh the rate is (crypto updates every minute; fiat tracks a daily institutional reference).

# Fiat, crypto, or a mix — from/to any supported code.
curl "https://api.startupaid.org/v1/convert?from=USD&to=NGN&amount=100" \
  -H "Authorization: Bearer sa_live_xxx"

# => {
#   "from": "USD", "to": "NGN", "amount": 100,
#   "result": 137503.95,
#   "rateAsOf": "2026-07-09T00:00:00Z"   // freshness of THIS pair
# }

# Crypto works the same way:
curl "https://api.startupaid.org/v1/convert?from=BTC&to=USD&amount=1" \
  -H "Authorization: Bearer sa_live_xxx"
# => { "result": 63837.0, "rateAsOf": "2026-07-10T08:11:23Z" }

# If you've set a CUSTOM rate on the pair, the response also gives you the
# market rate and your margin — so fintechs book profit with no extra math:
# => {
#   "result": 160000, "rate": 1600,            // at your rate
#   "marketRate": 1375.04, "marketResult": 137503.95,
#   "spread": { "rate": 224.96, "percent": 16.36, "amount": 22496.05 }
# }

Rates, currencies & freshness

List everything you can convert with /v1/currencies (fiat + crypto, with names and logos), get a full cross-rate table with /v1/rates/:base, and check per-source freshness with /v1/currency/status. Need NGN or another emerging currency, or your own spread? Pin a custom rate under Currencyin the dashboard and it applies to your account's conversions.

curl "https://api.startupaid.org/v1/currencies" \
  -H "Authorization: Bearer sa_live_xxx"

# => { "currencies": [
#   { "code": "USD", "type": "fiat" },
#   { "code": "NGN", "type": "fiat" },
#   { "code": "BTC", "type": "crypto", "name": "Bitcoin", "logo": "https://…" },
#   … 100+ total
# ]}

Push Messaging

Push — register a device

Push is a relay for your own apps. In the dashboard create an app (you can run many — a consumer app, a driver app, staging vs prod) and add its provider keys: an FCM service account for Android/web, an APNs .p8for iOS, or a generated VAPID keypair for browsers. Then register each end-user's device token under that app, keyed by userRef — your own user id. Everything is scoped to /v1/push/apps/:appId. Do this from your backend so the secret key never ships inside the client app.

# Register from YOUR backend so the secret key never ships in the app.
# platform: "fcm" (Android/web) · "apns" (iOS) · "webpush" (browser)
curl -X POST https://api.startupaid.org/v1/push/apps/APP_ID/devices \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "userRef": "user_42",
    "platform": "fcm",
    "token": "DEVICE_TOKEN"
  }'

# For web push, "token" is the browser PushSubscription JSON (endpoint + keys).

Push — send a notification

One send reaches iOS, Android, and browsersat once — we fan out to each platform using that app's credentials and return a per-device result. Target a single user, a list of users, specific tokens, or every device. Attach a link so a tap opens deep in your app or on the web. Tokens a provider rejects are retired automatically. Each notification attempt counts as one unit against your monthly allowance.

# One call reaches iOS, Android, and web — we fan out to each platform.
curl -X POST https://api.startupaid.org/v1/push/apps/APP_ID/send \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "target": { "userRef": "user_42" },
    "title": "Your order shipped 📦",
    "body": "Arriving Tuesday.",
    "link": "acme://orders/8921"
  }'

# target: { userRef } | { userRefs: [...] } | { tokens: [...] } | { all: true }
# => { "messageId": "…", "status": "sent", "recipients": 3, "sent": 3, "failed": 0 }

WhatsApp OTP

OTP — send a code

POST /v1/otp/send delivers a one-time passcode over WhatsApp, branded with the name of the app you send from. Pass an appId (create one via POST /v1/apps or in the dashboard) to choose the app — omit it to use your default app. Two modes:

  • Managed (default) — omit code and we generate it, store a hash, and verify it for you. The code expires in a few minutes and locks out after too many wrong attempts.
  • Delivery-only — already generate codes yourself? Pass your own code (4–10 letters/digits) and we simply deliver it. You can still call /verify, or verify on your side.

Each send counts as one unit against your monthly allowance. Numbers must be in E.164 format (e.g. +2348012345678).

# Managed — we generate, deliver, and verify the code.
curl -X POST https://api.startupaid.org/v1/otp/send \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+2348012345678", "appId": "app_9f3k2x8a…" }'
# => { "id": "…", "to": "+2348012345678", "status": "pending", "expiresAt": "…" }

# Delivery-only — pass your own code (4–10 letters/digits) and we just deliver it.
curl -X POST https://api.startupaid.org/v1/otp/send \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+2348012345678", "code": "482913" }'

OTP — verify a code

POST /v1/otp/verify checks the code your user entered against the latest one sent to that number. Returns { "verified": true } on an exact, in-time match — otherwise false. A successful verification consumes the code (it can't be reused), and verification is free — only sends are metered.

curl -X POST https://api.startupaid.org/v1/otp/verify \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+2348012345678", "code": "482913" }'
# => { "verified": true }

Social Automation

Social — schedule a post

Connect your channels — X, LinkedIn, Threads, Facebook, Instagram, or a custom webhook — once in the dashboard, then schedule a post to any of them with one call. Pass the connected channels ids and your content; add an optional imageUrl (required for Instagram) and a scheduledFor time, or omit it to publish now. Fetch GET /v1/social/posts for per-channel delivery results.

# Schedule a post to your connected channels (connect them in the dashboard).
# Omit "scheduledFor" to publish as soon as possible.
curl -X POST https://api.startupaid.org/v1/social/schedule \
  -H "Authorization: Bearer sa_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "channels": ["chan_9f2", "chan_7a1"],
    "content": "We just shipped Deep Links 🚀",
    "imageUrl": "https://cdn.acme.io/launch.png",
    "scheduledFor": "2026-07-20T09:00:00Z"
  }'

# => 202 { "id": "post_…", "status": "scheduled", "scheduledFor": "…" }
# List posts + per-channel results:  GET /v1/social/posts

Webhooks

Event webhooks

Register an endpoint to receive your mail's delivery events — delivered, bounced, complained, opened, clicked — as they happen. Add your endpoint under Webhooks in the dashboard; each can be scoped to one sending domain or receive events for all of them.

Every request is signed. Verify authenticity by recomputing an HMAC-SHA256 of the raw request body with your webhook's signing secret and comparing it to the X-startupaid-Signature header. Return a 2xx within 15s; non-2xx or timeouts retry with exponential backoff (up to 8 attempts).

POST  https://your-app.com/webhooks/startupaid
X-startupaid-Event: bounced
X-startupaid-Signature: sha256=<hmac>

{
  "id": "evt_…",
  "type": "bounced",
  "messageId": "…",
  "recipient": "user@example.com",
  "createdAt": "2026-07-11T08:04:00Z"
}

// verify (Node):
// const mac = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
// trusted = ("sha256=" + mac) === req.headers["x-startupaid-signature"];

Deep Links

Reference

Errors

Errors return a JSON body with an error field and a conventional HTTP status code.

StatusCodeMeaning
400bad_requestThe payload failed validation (missing or malformed fields, bad email).
401unauthorizedMissing or invalid API key.
403forbiddenThe from-address isn't a domain you've verified for this account.
404not_foundThe requested resource does not exist.
409conflictDuplicate — e.g. a contact with that email already exists in the audience.
429rate_limitedYou've exceeded your plan's rate limit — back off and retry.
502provider_errorAn upstream provider (SMTP or FX) failed; safe to retry.

Official SDKs

javascript

Node.js / TypeScript

npm i @startupaid/sdk
code

Python

pip install startupaid
terminal

Go

go get github.com/StartupAid-Org/startupaid-go
smart_toy

MCP server

npx -y @startupaid/mcp

Need a key?

Create an account and generate one in seconds.

Get your API key