The Jounce API
Manage your audience from your own code — subscribers, segments, custom fields, and signed webhooks.
Base URL https://api.jounce.com/v1 · machine spec at openapi.json.
Quickstart
Create a key in Jounce under Account → Integrations → API, then:
curl https://api.jounce.com/v1/me \
-H "Authorization: Bearer jk_live_…"
curl -X POST https://api.jounce.com/v1/subscribers \
-H "Authorization: Bearer jk_live_…" \
-H "Content-Type: application/json" \
-d '{"email":"jane@example.com","first_name":"Jane","segment_ids":["SEGMENT_ID"]}'
Authentication
- Bearer —
Authorization: Bearer jk_live_…(documented, primary). - HTTP Basic — key as the username, blank password. Works out of the box with generic HTTP clients and existing integration tooling.
- Keys are account-scoped and come in two scopes:
readandfull. A read key gets403 read_only_keyon any write. - Keys are shown once at creation and stored hashed. Revoking a key kills it on the next request.
- Keys are secrets — call the API from your server, never from a browser page.
Conventions
- Errors — proper status codes with one envelope:
{"error":{"type","code","message"}}.codeis a stable machine string, e.g.invalid_email,disposable_domain,read_only_key,create_limit_reached. - Rate limits — 120/min per account (batch 20/min), with
X-RateLimit-Limit/-Remaining/-Reseton every response andRetry-Afteron 429. New-subscriber creates cap at 5,000/day — ask support to raise it. - Pagination — cursor-based:
?limit=(max 100) and the response'snext_cursor→?cursor=. Stable on lists of any size; a page never shifts under you. - Timestamps — ISO 8601 UTC. Unknown is
null, never a fake zero.
Endpoints
| Method | Path | |
|---|---|---|
| GET /v1/me | The account + key behind your credentials | |
| GET /v1/subscribers | List subscribers — filter by status, segment_id, or exact email; cursor pagination | |
| POST /v1/subscribers | Create or update a subscriber by email | |
| POST /v1/subscribers/batch | Create or update up to 100 subscribers | |
| GET /v1/subscribers/{id_or_email} | One subscriber | |
| POST /v1/subscribers/{id_or_email}/segments | Add to segments | |
| DELETE /v1/subscribers/{id_or_email}/segments | Remove from segments | |
| POST /v1/subscribers/{id_or_email}/unsubscribe | Unsubscribe from all sends — works even for addresses not in Jounce yet | |
| GET /v1/segments | List segments | |
| POST /v1/segments | Create a segment | |
| GET /v1/segments/{id} | One segment | |
| GET /v1/custom-fields | List the custom-field registry | |
| POST /v1/custom-fields | Register a custom field | |
| GET /v1/forms | List forms | |
| GET /v1/forms/{id} | One form | |
| GET /v1/automations | List automations (read-only) | |
| GET /v1/webhooks | List webhook endpoints | |
| POST /v1/webhooks | Create a webhook endpoint — the secret is returned once | |
| GET /v1/webhooks/{id} | One webhook endpoint | |
| PUT /v1/webhooks/{id} | Update a webhook endpoint (re-enable resets the failure streak) | |
| DELETE /v1/webhooks/{id} | Delete a webhook endpoint |
Subscribers — how writes behave
POST is an upsert by email (safe to retry). Status is one of
subscribed · pending · unsubscribed · bounced · complained.
- An update never changes status. Writing to an unsubscribed contact updates their profile and segments; they stay unsubscribed. The response carries the real
statusand amailableflag — read it. - Suppression wins at birth. Creating an address that previously unsubscribed, bounced, or complained with your account creates it at that status, not
subscribed. An address that can't receive mail at all is refused withundeliverable. - Opt-outs sync in. POST
/unsubscribewith any email — even one Jounce hasn't seen — and it's recorded, so a later create is born unsubscribed. Push your other tools' opt-outs into Jounce freely. double_optin: true(create only) makes the contactpendingand sends your account's confirmation email — author one in Jounce first or the request is refused withno_confirmation_email.- Attest consent when you have it:
optin_ipandoptin_timestampare recorded in the contact's permission evidence. - Shared inboxes (
info@,sales@) are accepted with arole_addresswarning. Throwaway domains are refused (disposable_domain). - Custom fields ride as
custom_fields— string or boolean values, up to 30 keys per request.
{
"id": "jane@example.com",
"email": "jane@example.com",
"status": "subscribed",
"source": "api",
"first_name": "Jane",
"last_name": "",
"segments": [{ "id": "SEGMENT_ID", "name": "Customers" }],
"custom_fields": { "company": "Acme" },
"consent_at": "2026-07-22T10:00:00.000Z",
"created_at": "2026-07-22T10:00:00.000Z",
"updated_at": null
}
Adding a subscriber to a segment can enroll them in any automation that watches that segment — the same engine and safety gates as in-app adds. There is no separate enrolment endpoint; segments are the door.
Webhooks
Register an https endpoint and pick events. Deliveries are signed, retried
(5 attempts with backoff over ~6 hours), and an endpoint that fails 10 straight
deliveries is switched off — flip disabled back to re-arm it.
| Event | |
|---|---|
| subscriber.created | A subscriber was created — any source (bulk imports send one summary instead) |
| subscriber.updated | Profile or custom fields changed |
| subscriber.unsubscribed | They opted out — sync this into your other tools |
| subscriber.bounced | Their mailbox hard-bounced |
| subscriber.complained | They marked a send as spam |
| subscriber.added_to_segment | Joined a segment (includes segments set at creation) |
| subscriber.removed_from_segment | Left a segment |
| list.imported | A CSV import finished — one summary with counts, not one event per row |
Payload: { event_id, event_name, event_time, webhook_id, subscriber | list, segment? }.
Bulk imports deliberately do not fire per-row events.
Verify the signature
Every delivery carries Jounce-Signature: t=<unix>,v1=<hmac> —
HMAC-SHA256 of t + "." + rawBody with your endpoint's secret (shown once at creation):
import crypto from 'node:crypto'
// header: "t=1721650000,v1=6c2e…" rawBody: the EXACT request bytes
export function verifyJounceSignature(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false // 5-min replay window
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''))
}
Migrating from another platform?
The request shapes here follow the field names most email APIs already use
(first_name, custom_fields, segment_ids, double_optin…), and HTTP
Basic auth works as-is — most existing integrations port by swapping the base URL and key. Statuses use Jounce's
names, and pagination is cursor-based.
Jounce API v1 · openapi.json · Manage keys in Jounce → Integrations → API