Developers

Upspace Connect API

A JSON REST API for sending WhatsApp messages, managing contacts and templates, and triggering automations from your own systems. Available on plans that include API access. Base URL: https://upspaceconnect.dev/api/v1

Quick start

  1. Connect WhatsApp in Integrations.
  2. Create an API key under Developers → API keys with the scopes you need. The full key is shown once; we store only a hash.
  3. Send your first message:
curl -X POST "https://upspaceconnect.dev/api/v1/messages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-0001" \
  -d '{"to":"+23052512345","text":"Hello from our shop 👋"}'

Free-form text only reaches a customer within 24 hours of their last message. Outside that window, send an approved template.

Authentication

Send your key as a bearer token: Authorization: Bearer uc_live_xxxxxxxxxxxxxxxxxxxxxxxx. Keys start with uc_live_ (or uc_test_ in development), belong to one workspace, can be given an expiry date and can be revoked at any time. Every request also checks that the workspace has an active, paid subscription whose plan includes the API.

Call the API from your server only. Requests made from a web browser (anything that sends an Origin header) are refused with 403 BROWSER_REQUESTS_NOT_ALLOWED, because a key in website or app code can be copied by any visitor. From a website, send the request from your backend (PHP/WordPress, Node.js, Python…). Ready-to-paste examples are under Developers → Connect your website.

Check your key

GET/me· scope any

Confirms the key works and returns the workspace, the key's permissions (and any it's missing), and whether the plan can send messages. It's the safest first call from a new integration.

curl -X GET "https://upspaceconnect.dev/api/v1/me" \
  -H "Authorization: Bearer $API_KEY"
ScopeAllows
messages:sendSend text, media and template messages
messages:readRead a message and its delivery status
contacts:readList and read contacts
contacts:writeCreate and update contacts, tags and consent
templates:readList message templates and their approval status
automations:triggerStart automations with your own data

Messages

POST/messages· scope messages:send

Provide exactly one of text, template or media. Phone numbers are E.164 (for example +23052512345); numbers without a country code are read in your workspace's country. Returns 202 with the queued message; delivery updates arrive as webhooks.

Send a template

curl -X POST "https://upspaceconnect.dev/api/v1/messages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-ready" \
  -d '{"to":"+23052512345","template":{"name":"order_ready","language":"en","variables":["Aisha","#1042"]}}'

Send media

media.type is image, document, video or audio; media.url must be a public HTTPS URL.

curl -X POST "https://upspaceconnect.dev/api/v1/messages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"+23052512345","media":{"type":"document","url":"https://example.com/invoice-1042.pdf","filename":"invoice-1042.pdf","caption":"Your invoice"}}'

GET/messages/{id}· scope messages:read

Status is one of queued, processing, sent, delivered, read or failed. Failed messages include error.code and a plain-language error.message.

Templates

GET/templates?status=APPROVED· scope templates:read

Lists templates synced from Meta with their category, status, body and number of variables. Create and edit templates in the app; they are submitted to Meta for review automatically.

curl -X GET "https://upspaceconnect.dev/api/v1/templates?status=APPROVED" \
  -H "Authorization: Bearer $API_KEY"

Contacts

GET/contacts?q=&tag_id=&limit=50&cursor=· scope contacts:read

POST/contacts· scope contacts:write

GET/contacts/{id}· scope contacts:read

PATCH/contacts/{id}· scope contacts:write

Record marketing consent with marketing_consent and consent_source. Contacts who opt out (for example by sending STOP) are never sent marketing templates, even through the API.

curl -X POST "https://upspaceconnect.dev/api/v1/contacts" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: contact-L-2231" \
  -d '{"phone":"+23052512345","first_name":"Aisha","tags":["VIP"],"marketing_consent":true,"consent_source":"Checkout form","custom_fields":{"loyalty_id":"L-2231"}}'

Automations

POST/automations/{id}/trigger· scope automations:trigger

Starts a published automation whose trigger is “API”. Everything in data is available in messages as {{api.field}} variables, with fallbacks like {{api.name|there}}. Returns 202 with the execution; send the same Idempotency-Key to avoid starting it twice.

curl -X POST "https://upspaceconnect.dev/api/v1/automations/aut_xxxxxxxx/trigger" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{"contact":{"phone":"+23052512345"},"data":{"order":"1042","pickup":"Friday"}}'

Generic webhook URL

Automations with a “Webhook” trigger get a private URL (https://upspaceconnect.dev/hooks/hk_…) that accepts any JSON object — useful for form builders and no-code tools that can't set headers. The contact's phone is read from the path you configure (default contact.phone) and the payload is available as {{webhook.*}}. Treat the URL as a secret; regenerate it from the automation if it leaks.

Webhooks

Add HTTPS endpoints under Developers → Webhooks and choose events. Each delivery is a POST with a JSON body { id, type, created_at, workspace_id, data } and these headers:

  • Upspace-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">
  • Upspace-Event: the event type
  • Upspace-Delivery: a unique delivery id (use it to ignore repeats)

Events: message.received, message.sent, message.delivered, message.read, message.failed, contact.created, contact.updated, automation.started, automation.completed, automation.failed.

Respond with any 2xx within 10 seconds. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours; every attempt is visible in the delivery log, where you can also resend.

Verify the signature

import crypto from "node:crypto";

// Use the raw request body (a string), not a re-serialized object.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// verify(rawBody, req.headers["upspace-signature"], process.env.WEBHOOK_SECRET)

Idempotency

POST endpoints accept an Idempotency-Key header: 1–255 characters of letters, numbers and . _ : -. Repeating a request with the same key and body within 24 hours returns the original response (with Idempotent-Replayed: true) instead of acting twice. Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_REUSED; a repeat while the first request is still running returns 409 IDEMPOTENCY_IN_PROGRESS.

Errors

Errors share one format, and every response carries an X-Request-Id header to quote to support:

{
  "error": {
    "code": "TEMPLATE_REQUIRED",
    "message": "More than 24 hours have passed since this customer last wrote. Send an approved template.",
    "request_id": "req_…"
  }
}
StatusCommon codes
400INVALID_JSON, INVALID_IDEMPOTENCY_KEY
422INVALID_REQUEST, INVALID_PHONE_NUMBER, TEMPLATE_REQUIRED, CONTACT_OPTED_OUT, IDEMPOTENCY_KEY_REUSED
401UNAUTHENTICATED, INVALID_API_KEY
402SUBSCRIPTION_INACTIVE, FEATURE_NOT_INCLUDED, PLAN_LIMIT_REACHED
403INSUFFICIENT_SCOPE, WORKSPACE_SUSPENDED, BROWSER_REQUESTS_NOT_ALLOWED
404 / 409NOT_FOUND, WHATSAPP_NOT_CONNECTED, IDEMPOTENCY_IN_PROGRESS
413 / 429PAYLOAD_TOO_LARGE, RATE_LIMITED, TOO_MANY_FAILED_ATTEMPTS (both with Retry-After)

Rate limits

Each key may make 120 requests per minute (600 on plans with priority processing), and each IP address 300 per minute across all keys. After 20 requests with an invalid key, an IP address is blocked for up to 10 minutes (429 TOO_MANY_FAILED_ATTEMPTS). Responses include X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds), plus X-Quota-Used and X-Quota-Limit for the monthly allowance; when you exceed a limit you get 429 with Retry-After in seconds. Monthly API request allowances depend on your plan and are shown in Billing. Request bodies can be up to 256 KB. WhatsApp itself also limits how many new customers a number can message per day; failures from Meta are reported on the message.