REST API · v1

The Jounce API

Manage your contacts 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

Two ways in, both first-class: API keys for building on your own account — scripts, your backend, a CRM sync. OAuth2 for an app acting on behalf of Jounce users — they click "Connect Jounce" and approve; no key changes hands.

API keys

OAuth2 — apps acting on behalf of users

Conventions

Endpoints

MethodPath
GET /v1/meThe account + key behind your credentials
GET /v1/subscribersList subscribers — filter by status, segment_id, or exact email; cursor pagination
POST /v1/subscribersCreate or update a subscriber by email
POST /v1/subscribers/batchCreate or update up to 100 subscribers
GET /v1/subscribers/{id_or_email}One subscriber
POST /v1/subscribers/{id_or_email}/segmentsAdd to segments
DELETE /v1/subscribers/{id_or_email}/segmentsRemove from segments
POST /v1/subscribers/{id_or_email}/unsubscribeUnsubscribe from all sends — works even for addresses not in Jounce yet
GET /v1/segmentsList segments
POST /v1/segmentsCreate a segment
GET /v1/segments/{id}One segment
GET /v1/custom-fieldsList the custom-field registry
POST /v1/custom-fieldsRegister a custom field
GET /v1/formsList forms
GET /v1/forms/{id}One form
GET /v1/automationsList automations (read-only)
GET /v1/webhooksList webhook endpoints
POST /v1/webhooksCreate 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.

{
  "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,
  "mailable": true,
  "unverified_fields": []
}

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 (10 tries, waiting longer each time, over about 6 hours), and an endpoint that fails 10 straight deliveries is switched off — flip disabled back to re-arm it.

Event
subscriber.createdA subscriber was created — any source (bulk imports send one summary instead)
subscriber.updatedProfile or custom fields changed
subscriber.subscribedTheir status became subscribed, as when they confirm from your confirmation email (someone added already subscribed sends only subscriber.created)
subscriber.unsubscribedThey opted out — sync this into your other tools
subscriber.bouncedTheir mailbox hard-bounced
subscriber.complainedThey marked a send as spam
subscriber.added_to_segmentJoined a segment (includes segments set at creation)
subscriber.removed_from_segmentLeft a segment
list.importedA CSV import finished — one summary with counts, not one event per row

Payload: { event_id, event_name, event_time, webhook_id, changed_by, changed_via, subscriber | list, segment? }. Bulk imports deliberately do not fire per-row events. A delivery can arrive more than once; every copy carries the same event_id, so use it to drop a repeat.

Who made the change

Every delivery says who made the change (changed_by) and the door it came through (changed_via).

changed_viachanged_by
formpublicYour sign-up form. Anyone who knows an address can send it, so it may not be the person.
confirmcontactThey pressed Confirm on the page your confirmation email links to. Opening the link alone changes nothing.
linkcontactA link action in an email you sent them. A mail scanner that follows links can set one off too.
unsubscribecontactThe unsubscribe link, or the unsubscribe button in their inbox.
preferencescontactThey saved their preferences page, from the Manage preferences link in an email you sent them.
complaintcontactThey marked an email as spam, and their mailbox provider told Jounce.
manualaccountYour team, in Jounce.
csvaccountA CSV import.
apiaccountAn API key or a connected app.
email_changeaccountYour team changed their email address. The contact arrives as subscriber.created at the new address.
eraseaccountYour team erased their personal data. Deleting a subscriber sends no event, so no delivery carries this.
field_deleteaccountYour team deleted a custom field from the whole workspace. Jounce clears it from each subscriber in the background and sends no event for that, so no delivery carries this.
automationsystemA step in one of your automations. A sign-up form may have started it.
bouncesystemTheir mailbox rejected an email.
domain_checksystemJounce checked their domain and found it can’t receive mail, or that it can again.
operatorsystemJounce’s own team lifted a block on their address, after the person or your team asked.

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.

Changes

What changed in v1, newest first. A change here never breaks a working integration: new keys can appear in any response or delivery, so ignore the ones you don't recognize.

Jounce API v1 · openapi.json · Manage keys in Jounce → Integrations → API