# SplashSend API > SplashSend is email marketing that funds water projects. This guide is for AI tools building an app that works with a SplashSend account: how an app connects, what it may do, and every endpoint, with the fields each one takes. ## Two kinds of app - **Private app**: an account's own app, made in SplashSend under Apps, then Apps you've made. It calls the API with its key: `Authorization: Bearer sk_live_...`. Only that account can use it, and nobody needs to check it. It starts in test mode. - **Public app**: made for every SplashSend account. Each account connects it through SplashSend's allow screen (OAuth 2.1 with PKCE, below) and agrees to what it asks for. SplashSend checks it before it's listed in Apps. ## The address Every endpoint below is under https://splashsend.com/api/v1. Send JSON with `Content-Type: application/json`. ## Keeping a key safe Keep keys, client secrets and tokens on a server, in an environment variable or a secret, never in code that runs in a browser or a phone app, and never in a repository. A request from a web page (one with an `Origin` header) is refused for an app made under Apps, with the code `key_in_web_page`. ## Test mode A private app starts in test mode, and so does a public app on its maker's own account. In test mode it can add people and send events, but nothing it does starts a drip, sends an email or sends a webhook. Its maker switches it to live in SplashSend. ## What an app may do Each endpoint needs one permission. A request without it gets a 403 with the code `forbidden`. - `contacts:read`: See the people on your list and their details - `contacts:write`: Add people and change their details - `lists:read`: See your lists - `lists:write`: Make lists, and put people on them or take them off - `events:read`: See the events on your account, such as a booking or a review - `events:write`: Send in events, such as a review someone left - `orders:read`: See the orders on your account - `orders:write`: Send in orders from a shop - `products:read`: See your products - `products:write`: Add and change your products - `campaigns:write`: Write draft campaigns for someone on your team to finish and send - `campaigns:send`: Send campaigns you've designed to your groups and lists - `data:read`: See your data sets - `data:write`: Change your data sets An app sends an email only with `campaigns:send`, by sending one of the account's own designs to a group, a list or everyone through the same checks as the Send button. The same design can't go to the same group or list twice within a day. A public app can never subscribe someone who unsubscribed; an account's own private app can, only by sending `"resubscribe": true` for someone who asked to hear from it again. ## Endpoints ### Account Which account a key or token is for, and what it may do. #### GET /me Who this key or token is for. The account's name, the app the key or token belongs to, whether that app is in test mode, and what it may do. Any working key or token can ask, whatever it may do. `account` is the brand's or sender's name, and null for an account with neither. ### People The people on your list. Their details, such as a birthday, are called `properties` here, and are named the way they are in SplashSend. #### GET /contacts (needs `contacts:read`) List people. Newest first. Query: `status` (Only people with this status); `email` (Only the person at this address, to look someone up before changing them). #### POST /contacts (needs `contacts:write`) Add or update a person. Adds a person by email address, subscribed, or updates the person already at that address. Someone already here who never subscribed, for example someone first seen through an event or an order, is subscribed, and this request is recorded as their consent. Someone who unsubscribed stays unsubscribed unless you send `"resubscribe": true`, so only send it when they've asked to hear from you again. A stopped address, one that bounced or was reported as spam, is never subscribed. When this request subscribes someone, their welcome drips start and contact.subscribed is sent, unless you send `"startAutomations": false`. `source` is set only for someone new. To add many people at once, use POST /contacts/import. `preferences` switches on the email topics you name. For someone new, the topics you don't name are switched off. For someone already here, nothing is switched off. `meta` says what happened. `warnings` explains a kept opt-out, a stopped address and anything that was skipped. Body: - `email` (string, required): Their email address. - `firstName` (string): Their first name. - `lastName` (string): Their last name. - `name` (string): Their full name, split into first and last. Ignored when `firstName` or `lastName` is sent. - `source` (string): Where they came from, such as `website`. Set only for someone new. - `properties` (PropertyValues) - `preferences` (string[]): The names of the email topics they chose. - `resubscribe` (boolean): Subscribe someone who unsubscribed. Send it only when they've asked to hear from you again. - `startAutomations` (boolean): Start their welcome drips and send contact.subscribed when this request subscribes them, and start drips that watch the details you set. Send false for someone from your records rather than a new sign-up. Example: ```json { "email": "sam@example.com", "firstName": "Sam", "lastName": "Lee", "source": "website", "properties": { "Birthday": "1990-05-15" }, "preferences": [ "Newsletter" ] } ``` #### POST /contacts/import (needs `contacts:write`) Import people. Adds or updates up to 500 people in one request, for a migration or a backfill. Someone new is added subscribed, with consent recorded, unless their row says `"subscriptionStatus": "unsubscribed"`, for example someone who had opted out of your old email service. Someone already here keeps their subscription status and source, so an import never brings back someone who unsubscribed. Their name and details are updated from the row. Automations are off unless you send `"startAutomations": true`. Without it nobody starts a welcome drip or a drip that watches a detail or a list, and contact.subscribed isn't sent. Each row is handled on its own. A row that fails is listed in `meta.errors` and the rest carry on. If your plan's people limit is reached, the import stops: `meta.planLimitReached` is true, and `meta.stoppedAtIndex` is the row it stopped at. That row and the rows after it weren't added. To add everyone to a list, send `listId`, which needs `lists:write` as well. Body: - `contacts` (ImportContactInput[], required): The people, up to 500. - `startAutomations` (boolean): Start welcome drips, drips that watch a detail or a list, and contact.subscribed for these people. Leave it out for people from your records. - `listId` (string): Add everyone imported to this list. Needs `lists:write`. - `source` (string): The `source` for someone new whose row has none. Defaults to `api_import`. Example: ```json { "contacts": [ { "email": "sam@example.com", "firstName": "Sam", "lastName": "Lee" }, { "email": "alex@example.com", "name": "Alex Morgan", "subscriptionStatus": "unsubscribed" } ], "source": "old_platform" } ``` #### GET /contacts/{id} (needs `contacts:read`) Get a person. Includes their details in `properties`, keyed by name. A detail with no value is left out. #### PATCH /contacts/{id} (needs `contacts:write`) Update a person. Changes only the fields you send. Setting `subscriptionStatus` to `subscribed` subscribes them, records their consent and sends contact.subscribed. It doesn't start welcome drips, so use POST /contacts for a new sign-up. A stopped address can't be subscribed and gets a 409 (`stopped_address`). Setting it to `unsubscribed` unsubscribes them and sends contact.unsubscribed. contact.updated is sent only when their name, source or details changed. Body: - `firstName` (string): Their first name. - `lastName` (string): Their last name. - `name` (string): Their full name, split into first and last. Ignored when `firstName` or `lastName` is sent. A blank value never clears a stored name. - `source` (string): Where they came from. - `subscriptionStatus` (string) - `properties` (PropertyValues) Example: ```json { "firstName": "Samantha", "properties": { "Birthday": "1990-05-15" } } ``` #### DELETE /contacts/{id} (needs `contacts:write`) Unsubscribe a person. Unsubscribes the person and sends contact.unsubscribed. Nothing is deleted: they stay in your account with their history. Someone already unsubscribed is left as they are. #### GET /contacts/{id}/properties (needs `contacts:read`) List a person's details. Every detail in your account, with this person's value. A detail they have no value for has `""`. #### PUT /contacts/{id}/properties (needs `contacts:write`) Set a person's details. Sets the details you send and leaves the others as they are. A name that isn't a detail in your account is skipped with a warning, and if none of the names are details, the request gets a 400. contact.updated is sent when a value changed, and drips that watch a detail start when its value changes. Body: - `properties` (PropertyValues, required) Example: ```json { "properties": { "Birthday": "1990-05-15" } } ``` #### GET /contacts/{id}/profile (needs `contacts:read`) Get a person's profile. The person with their details, their consent for email and text messages, how their emails did, what happened to them recently, their orders and the data-set rows matched to them, in one request. Their emails are counted the way Reports counts them: an email sent again after a failed try counts once, and a click made by link-checking software isn't counted. What happened to them also needs `events:read`, their orders `orders:read`, their data-set rows `data:read`, and the names of lists that stop mail reaching them `lists:read`. What a key can't see is left out, and `meta.left_out` names it. ### Other #### PARAMETERS /contacts/{id} . #### PARAMETERS /contacts/{id}/properties . #### PARAMETERS /contacts/{id}/profile . #### PARAMETERS /lists/{id} . #### PARAMETERS /lists/{id}/members . #### PARAMETERS /webhooks/{id} . #### PARAMETERS /report-cards/{key} . #### PARAMETERS /drip-steps/{key} . #### PARAMETERS /email-blocks/{key} . #### PARAMETERS /data/{key} . #### PARAMETERS /data/{key}/rows . #### PARAMETERS /data/{key}/rows/{rowId} . ### Lists Lists you add people to yourself. A list set to never be emailed (`blockSending`) keeps everyone on it out of every send, without changing their subscriptions. #### GET /lists (needs `lists:read`) List your lists. Newest first. Query: `search` (Only lists whose name or description contains this). #### POST /lists (needs `lists:write`) Create a list. Creates a list you add people to yourself. Send `"blockSending": true` to make it a "Never email these people" list from the start. An account can have up to 500 lists. Example: ```json { "name": "Workshop regulars", "description": "People who have been to three or more workshops." } ``` #### GET /lists/{id} (needs `lists:read`) Get a list. Includes how many people are on it. #### PATCH /lists/{id} (needs `lists:write`) Update a list. Changes only the fields you send. Example: ```json { "name": "Workshop regulars 2026" } ``` #### DELETE /lists/{id} (needs `lists:write`) Delete a list. Deletes the list. The people on it stay in your account, and their subscriptions don't change. #### GET /lists/{id}/members (needs `lists:read`) List a list's members. Newest additions first. A member's `email` and `name` are included only for a key with `contacts:read`. #### POST /lists/{id}/members (needs `lists:write`) Add people to a list. Adds people already in your account, by id or email address, up to 500 in one request. Nobody is created here: to add new people and put them on a list in one go, use POST /contacts/import with `listId`. Ids and addresses that aren't in your account come back in `meta`. Drips that watch this list start only if you send `"startAutomations": true`. Example: ```json { "emails": [ "sam@example.com" ], "contactIds": [ "cm4u2x8yq0004l208t6r3b1nh" ] } ``` #### DELETE /lists/{id}/members (needs `lists:write`) Remove people from a list. Takes people off the list, by id or email address. The people and their subscriptions don't change. Example: ```json { "emails": [ "sam@example.com" ] } ``` ### Events Things people do, such as a booking or a sign-up, recorded so they can start drips. #### GET /events (needs `events:read`) List events. Newest first. An event's `email` is included only for a key with `contacts:read`. Query: `name` (Only events with this name); `email` (Only this person's events. Needs `contacts:read`, and without it the request gets a 403). #### POST /events (needs `events:write`) Record an event. Records something a person did, such as booking a class, and starts any drip whose trigger is an event with this name. The event is attached to the person at `email`. Someone new is added unsubscribed, because an event isn't consent to marketing email, and they count towards your plan's people limit. Drips start for an event from the last 3 days, or one dated ahead. An older event is recorded without starting any, unless you send `"startAutomations": true`. Send `false` to record any event without starting drips. Send `external_id` so a retried event is recorded once. A second event with the same `external_id` records nothing and starts nothing, and the response is the first event with `meta.duplicate` set to true. Body: - `email` (string, required): The person who did it. - `name` (string, required): The event's name, such as `class_booked`. - `properties` (object): Anything else about the event, up to 16KB as JSON. - `occurred_at` (string): When it happened. Defaults to now. - `external_id` (any): Your id for this event, so a retry is recorded once. - `startAutomations` (boolean): `true` starts drips for an event of any age. `false` starts none. Example: ```json { "email": "sam@example.com", "name": "class_booked", "properties": { "class": "Beginners wheel", "seats": 2 }, "external_id": "booking-10482" } ``` ### Activity Everything that happened in your account, read in one place: SplashSend's own events and the events your systems send. SplashSend's own use the same names as the webhooks. #### GET /activity (needs `events:read`) List what happened. Everything that happened, newest first, or what happened to one person with `email`. Each event says whether it's one of SplashSend's own (`kind: built_in`) or one your systems sent (`kind: custom`), so an event you named `order.placed` yourself is never mistaken for an order. An email is listed once, as `email.sent` with its `status`, however many tries it took. Ask for `email.delivered`, `email.bounced` or `email.failed` by `name` to list the emails with that outcome under that name. A click made by link-checking software isn't listed. Events about a person (emails, joining and leaving, form answers, drips started and back in stock requests) need `contacts:read`, and orders and unfinished checkouts need `orders:read`. What a key can't see is left out, and `meta.left_out` names it. An event's `email` is sent only to a key with `contacts:read`. To page, send the `meta.next_cursor` of one page as `cursor` to get the next. It's null on the last page. Query: `cursor` (Where the last page ended: its `meta.next_cursor`); `name` (Only this event: one from GET /activity/catalogue, such as `email.clicked`, or one your systems send); `kind` (`built_in` for SplashSend's own events, `custom` for the ones your systems send); `email` (Only this person's events. Needs `contacts:read`, and without it the request gets a 403); `since` (From this time: a date such as `2026-10-01` or a time such as `2026-10-01T09:00:00Z`); `until` (Up to this time, not including it). #### GET /activity/count (needs `events:read`) Count an event. How many times an event happened, for the whole account or one person, over a stretch of time, counted the way GET /activity lists it: an email sent again after a failed try counts once, and a click made by link-checking software isn't counted. For a name that is both one of SplashSend's events and one your systems send, `kind=custom` counts yours. Query: `name` (The event to count: one from GET /activity/catalogue, or one your systems send); `kind` (`built_in` for SplashSend's own events, `custom` for the ones your systems send); `email` (Only this person's events. Needs `contacts:read`, and without it the request gets a 403); `since` (From this time: a date such as `2026-10-01` or a time such as `2026-10-01T09:00:00Z`); `until` (Up to this time, not including it). #### GET /activity/catalogue (needs `events:read`) The events SplashSend records. Every kind of event SplashSend records: its name, what it carries in `properties`, how it's counted, and any scope needed beyond `events:read` to see it. ### Orders Orders from a shop that isn't connected through Shopify. #### GET /orders (needs `orders:read`) List orders. Orders sent in with POST /orders, newest order first. `email` is included only for a key with `contacts:read`. #### POST /orders (needs `orders:write`) Add or update an order. Records an order from your shop by `order_id`. To update it, send the same `order_id` again with only what changed. A buyer new to your account is added as a customer who can't be emailed, unless `accepts_marketing` is true because they agreed to marketing email at checkout. A buyer already here keeps their subscription status. If your plan's people limit is reached, the order is saved without a person and `meta.warning` says so. When this order subscribes a new buyer, their welcome drips start and contact.subscribed is sent if the order is from the last 3 days. Send `"startAutomations": true` to start them for an older order, or `false` to never start them. Drips for an order placed, a first order or a repeat order run once, when a new order from the last 3 days arrives, and order.placed is sent then. "Order shipped" drips run, and order.fulfilled is sent, when the order moves into a shipped status (`fulfilled`, `completed`, `complete`, `shipped` or `delivered`) and is from the last 30 days. Body: - `order_id` (any, required): Your order number or id. - `email` (string): The buyer's email address. Needed for a new order. - `total` (number): The order's total. Needed for a new order. - `currency` (string): A three-letter currency code, such as `GBP`. Defaults to `USD`. - `items` (object[]): What was bought. Each item's `name`, or `title`, names it in review emails. - `status` (string): The order's status in your shop. `fulfilled`, `completed`, `complete`, `shipped` and `delivered` count as shipped. - `order_date` (string): When it was placed. Defaults to now. - `source` (string): Where it came from, such as your shop's name. Defaults to `woocommerce`. - `accepts_marketing` (boolean): True when the buyer agreed to marketing email at checkout. Used only when this order adds them. - `startAutomations` (boolean): For a buyer this order subscribes: `true` starts their welcome drips for an order of any age, and `false` never starts them. Example: ```json { "order_id": "1042", "email": "sam@example.com", "total": 42.5, "currency": "GBP", "items": [ { "name": "Speckled mug", "quantity": 2, "price": 18 } ], "status": "processing", "order_date": "2026-10-09T10:30:00Z" } ``` ### Products Your product catalogue, for product blocks in emails. #### GET /products (needs `products:read`) List products. Products from your Shopify store and products sent with POST /products, newest first. #### POST /products (needs `products:write`) Add or update products. Adds products to your catalogue, or updates them, by `product_id`, so they can appear in product blocks in emails. Send one product, or `{ "products": [ … ] }` with up to 100. An update changes only the fields you send. A field sent as `null` is cleared, apart from `title` and `handle`, which can't be. `title` is needed only for a new product. `status` is `active`, `draft` or `archived`, and only active products appear in emails. Other platforms' words are understood: WooCommerce's `publish` is active, `private` and `pending` are drafts, and `trash` is archived. A status that isn't recognised is an error for that product. A product that can't be saved is listed in `meta.errors`, and the others are saved. If none can be saved, the request gets a 400. ### Groups Groups pick people by rules and keep themselves up to date. A campaign can go to one. #### GET /groups (needs `contacts:read`) List groups. Your groups, pinned ones first. The groups of addresses that bounced or reported spam aren't listed, because nobody in them is emailed. `built_in` marks the two SplashSend keeps for you, Everyone and Subscribed. ### Campaigns Draft a campaign for someone on your team to finish, or send one of your designs to a group, a list or everyone, now or at a time. A send goes through the same checks as the Send button in SplashSend. #### GET /designs (needs `campaigns:write`) List designs. The designs in your Designs library, most recently changed first, for choosing one to send. `subject` is the subject line saved with a design, if it has one. #### POST /campaigns (needs `campaigns:write`) Draft or send a campaign. Without `send`, makes a draft for someone on your team to finish and send in SplashSend. It needs `campaigns:write`. With `send`, sends one of your designs to a group, a list or everyone (`"now"`), or schedules it (a time). It needs `campaigns:send`, and `design_id`, who it goes to and a subject line. It goes through the same checks as the Send button: a verified sender, your plan's sends, the unsubscribe link, and only subscribed people, leaving out stopped addresses, lists set to never be emailed, people who opted out of the campaign's topic and anyone their own email frequency would skip. A send that's refused makes nothing, and the error says why. The same design can't go to the same group, list or everyone twice within a day, whoever sent it first: that's refused with `sent_recently`. An app in test mode gets a draft and sends nothing. Body: - `name` (string): What the campaign is called in SplashSend. Defaults to the design's name. - `subject` (string): The subject line. Defaults to the one saved with the design. - `preview_text` (string): The line shown after the subject in the inbox. Defaults to the design's own. - `design_id` (string): A design from GET /designs. Needed to send. - `group_id` (string): Send it to a group from GET /groups. - `list_id` (string): Send it to a list from GET /lists. - `everyone` (boolean): Send it to everyone subscribed. - `send` (string): `now`, or a time to schedule it for, such as `2026-10-24T09:00:00Z`. Leave it out for a draft. Needs `campaigns:send`. Example: ```json { "design_id": "cm4u2xi5l0012l208u7i9o1pa", "group_id": "cm4u2xh4k0011l208y6u8i0op", "subject": "Three new glazes are in", "send": "now" } ``` ### Webhook subscriptions An app's own subscriptions to the account's events, so it hears of them as they happen (REST hooks, as Zapier uses). Messages are the same as Setup → Webhooks sends, signed with the subscription's own secret, which is shown once, when it's made. An app may subscribe to an event it could read through GET /activity, so most events need `contacts:read`. A `410 Gone` from the address ends the subscription, and removing the app ends all of its subscriptions. An app sees and ends only its own, and the account sees each one in Setup → Webhooks, marked as added by an app. #### GET /webhooks List this app's subscriptions. The subscriptions this app holds on this account, newest first. `active` is false while one is paused, after a day of failed deliveries. #### POST /webhooks Subscribe to events. Sends each of these events to `url` as it happens. `event_name` narrows `event.recorded` to one of your own events by name, such as `Class booked`. `step_note` narrows `drip.step` to the Notify your systems steps with that note, matched without regard to capital letters. Each narrows only its own event. The answer holds the signing `secret`, the only time it's shown. An app may hold 50 subscriptions on an account. Body: - `url` (string, required): An https address. Events can't be sent to internal or private addresses. - `events` (string[], required): The events to send, as Webhooks lists them. - `event_name` (string): Only this one of your own events, with `event.recorded`. - `step_note` (string): Only Notify your systems steps with this note, with `drip.step`. Example: ```json { "url": "https://hooks.example.com/splashsend/123", "events": [ "event.recorded" ], "event_name": "Class booked" } ``` #### GET /webhooks/{id} Read a subscription. #### DELETE /webhooks/{id} End a subscription. Nothing more is sent to it, and its delivery log goes with it. ### Report cards Cards an app adds to Reports from the events it sends. A card counts one event, or adds up one of its numbers, over the dates a dashboard shows, split by day, by week or by one of the event's details. The figures are worked out when the account looks at them, and the app never sees them. #### GET /report-cards (needs `events:write`) List this app's report cards. #### GET /report-cards/{key} (needs `events:write`) Read a report card. #### PUT /report-cards/{key} (needs `events:write`) Make or replace a report card. Makes the card, or replaces this app's card with this key. Sending the same card again changes nothing, so it's safe to send each time the app starts. `event` is the name of one of your events. A card counts them, or with `sum` adds up that number from each one (as money with `money`). `split` is `none`, `day`, `week` (the default) or `property`, which splits by the event detail named in `property`. `chart` is `number` for a card with no split, `line` for one split by day or week, or `bars` or `table` for any split. Example: ```json { "name": "Classes booked, by class", "description": "Bookings made through Hearth in these dates.", "event": "Class booked", "split": "property", "property": "class", "chart": "bars" } ``` #### DELETE /report-cards/{key} (needs `events:write`) Remove a report card. It comes off every dashboard it's on. ### Drip steps Steps an app adds to the account's drip builder. Each is listed under From your apps when someone adds a step, with the app's name, and asks for the settings the app wants. When a person reaches it, the app hears a `drip.step` webhook about them, with the step's key and the settings, through its own subscription (POST /webhooks). Nothing else hears it. #### GET /drip-steps (needs `events:read`) List this app's drip steps. #### GET /drip-steps/{key} (needs `events:read`) Read a drip step. #### PUT /drip-steps/{key} (needs `events:read`) Make or replace a drip step. Makes the step, or replaces this app's step with this key. Sending the same step again changes nothing, so it's safe to send each time the app starts. `fields` are the settings the account fills in on the step, up to 6, each sent in `values` when someone reaches it. To hear the step, subscribe to `drip.step` with POST /webhooks. Example: ```json { "name": "Offer the next class", "description": "Hearth offers them a place on the next class of the kind you choose.", "fields": [ { "key": "class_type", "label": "Kind of class", "type": "choice", "options": [ "Wheel throwing", "Glazing", "Hand building" ], "required": true }, { "key": "discount", "label": "Discount (%)", "type": "number" } ] } ``` #### DELETE /drip-steps/{key} (needs `events:read`) Remove a drip step. It leaves the builder's list. A drip that already uses it sends nothing at that step, and the person carries on. ### Email blocks Blocks an app adds to the account's email designer, listed under From your apps. A block shows rows from a data set the app made, as cards with a title, a line, a picture and a button, filled in when the email sends: a set for everyone gives its first rows, and a set matched to people gives each person their own row. #### GET /email-blocks (needs `data:write`) List this app's email blocks. #### GET /email-blocks/{key} (needs `data:write`) Read an email block. #### PUT /email-blocks/{key} (needs `data:write`) Make or replace an email block. Makes the block, or replaces this app's block with this key. `data_set` is the key of a set this app made (POST /data), switched on and not kept out of emails. `title`, `text`, `image` and `link` name its fields, by key or label: each card shows the title, a line of text and a picture, and its button opens the link. `count` is how many cards at most, 1 to 6. Example: ```json { "name": "Upcoming classes", "description": "The next classes with places left.", "data_set": "classes", "title": "Class", "text": "When", "image": "Photo", "link": "Booking link", "button": "Book a place", "count": 3 } ``` #### DELETE /email-blocks/{key} (needs `data:write`) Remove an email block. It leaves the designer's list. A design that already uses it shows nothing there when it sends. ### Data sets Facts your business keeps that aren't about one person, such as your catalogue, form answers or a timetable. A set you keep yourself takes rows over the API. #### GET /data (needs `data:read`) List data sets. Every data set that's switched on, with its fields. Use it to find a set's `key`. #### POST /data (needs `data:write`) Create a data set. Creates a data set you keep yourself and send rows to. An account can have up to 100 data sets. `fields` lists its columns, each with a `label` and a `type`. Each field gets a `key` made from its label, which is what rows and email tags use. A set sent with no fields gets one, called "Name". `audienceMode` says who a row is for. `everyone`, the default, gives everyone the same row. `matched` gives each person the row that matches one of their details: send `matchOn` with the person's `detail` and the set's `field`. A matched set made without `matchOn` gives nobody a row until you choose them in the app. `internal` keeps the set out of emails, for use in groups. A set that picks people by rules is set up in the app. `joins` links the set to up to 4 others. Each join pairs one of this set's fields with a field of another set. When a person has a row of this set, the other set's tags read its row with the same value in that field, so a set of shops joined to the weather on the town gives each person the forecast for their own shop. Values match when they're the same apart from capital letters and spaces at either end. The response is the new set's `key`. Body: - `name` (string, required): The set's name. Its `key` is made from it. - `description` (string) - `category` (string): Where it's filed in the app. Defaults to "Kept by you". - `fields` (DataFieldInput[]): Its columns, up to 40. - `audienceMode` (string): Who a row is for. - `matchOn` (MatchOn) - `joins` (DataJoin[]): Other sets it joins, up to 4. Example: ```json { "name": "Workshops", "description": "Upcoming workshops and how many places are left.", "fields": [ { "label": "Title", "type": "words" }, { "label": "Date", "type": "date" }, { "label": "Price", "type": "money" }, { "label": "Places left", "type": "number" } ] } ``` #### GET /data/{key} (needs `data:read`) Get a data set. Its fields, and how many rows it has. #### PATCH /data/{key} (needs `data:write`) Update a data set. Changes the fields you send. `name`, `description`, `category` and `matchOn` work on any set. `fields` works only on a set you keep yourself. `fields` is the whole new list of columns. A field sent with its `key` keeps its values, and keeps any `label`, `type` or `example` you leave out. A field you leave out of the list is removed. A field that is removed and later added back starts empty. The field a matched set matches people on, and a field a join uses, can't be removed. `matchOn` makes it a matched set and says what it matches on, as on create. `joins` works on any set and replaces its joins, as on create. Send an empty list to remove them. A join to a set that has since been switched off can stay, and starts working again when that set is back on. Body: - `name` (string) - `description` (string) - `category` (string) - `fields` (DataFieldInput[]): The whole new list of columns. - `matchOn` (MatchOn) - `joins` (DataJoin[]): The whole new list of joins. Example: ```json { "name": "Workshops and classes", "description": "Upcoming workshops and classes, with places left." } ``` #### DELETE /data/{key} (needs `data:write`) Delete a data set. Deletes a set you keep yourself, or one added through "Connect an app", with all its rows. Ideas it put on your calendar are removed too. A built-in set can't be deleted: switch it off in the app instead. #### GET /data/{key}/rows (needs `data:read`) List a data set's rows. Reads the rows of any data set that's switched on. A built-in set is read when you ask, so its rows are current. Each row's values are keyed by field key. A row in a set you keep yourself also has its `id`, which you send to update or delete it. A column labelled ID is keyed `id_2`, so it never hides the row's id. Some sets leave out rows whose date has passed. Send `include_past=true` to list those too. Without `contacts:read`, columns holding someone's email are left out, and form answers are limited to the ones shown on your site, with first names only. Query: `limit` (How many rows to read); `offset` (How many rows to skip); `include_past` (Include rows whose date has passed). #### POST /data/{key}/rows (needs `data:write`) Add rows. Adds rows to a set you keep yourself, up to 5,000 in one request (4MB). A set holds up to 5,000 rows. Each row is an object of values. A key can be a field's key, in any case, or its label. Keys that don't match a field are skipped and listed in `warnings`. A row where no key matches a field is refused with a 400, and so is a request whose rows are all empty. `"mode": "replace"` swaps every row in the set for the rows you send. A row that comes back unchanged keeps its `id` and doesn't start drips waiting for a new row. A date column is stored as YYYY-MM-DD. A value such as 14/10/2026 or 14 Oct 2026 is read as a date too, day first unless your account's country is the US or Canada. Body: - `rows` (RowValues[], required): The rows, each an object of values. - `mode` (string): `append` adds the rows after the ones already there. `replace` swaps every row in the set for these. Example: ```json { "rows": [ { "title": "Wreath making", "date": "2026-12-05", "price": 45, "places_left": 8 }, { "title": "Beginners wheel", "date": "2026-12-12", "price": 60, "places_left": 4 } ] } ``` #### PATCH /data/{key}/rows (needs `data:write`) Update rows. Changes up to 500 rows in one request, each found by its `id`. Send only the values that change, and the rest of the row stays as it is. Keys are matched the same way as when adding rows. An id that isn't in the set is skipped, and `updated` is how many were found. Body: - `updates` (object[], required): Up to 500 changes. Example: ```json { "updates": [ { "id": "cm4u5d0re000bl208h4w1s6kc", "values": { "places_left": 6 } } ] } ``` #### DELETE /data/{key}/rows (needs `data:write`) Delete rows. Deletes up to 1,000 rows by `id`. Ids that aren't in the set are ignored. To clear every row, send `{ "all": true }` instead of `ids`. Body: - `ids` (string[]): The ids of the rows to delete. - `all` (boolean): Delete every row in the set. Send it only when you mean to empty the set. Example: ```json { "ids": [ "cm4u5d0re000bl208h4w1s6kc", "cm4u5d0re000cl208x9j2v3mn" ] } ``` #### PATCH /data/{key}/rows/{rowId} (needs `data:write`) Update a row. Changes one row. Send the values that change, inside `values` or on their own. Keys are matched the same way as when adding rows. Body: - `values` (RowValues) Example: ```json { "values": { "places_left": 6 } } ``` #### DELETE /data/{key}/rows/{rowId} (needs `data:write`) Delete a row. Deletes one row from a set you keep yourself. ## Webhooks SplashSend sends Events SplashSend sends to your own systems as they happen. Add endpoints in Setup → Webhooks, where each one gets its own signing secret and receives only the events you pick. **Message:** a POST with a JSON body of `{ id, event, timestamp, data }`. `id` (`evt_…`) stays the same through every retry and any later redelivery, so use it to skip a message you've already handled. `timestamp` is when this copy was sent, so a redelivery has a new one and a check on its age still passes. `data` holds ids and plain values, and never a secret. **Signature:** `X-SplashSend-Signature` is the hex HMAC-SHA256 of the raw request body, keyed with the endpoint's signing secret. Compute it over the body exactly as it arrived, and compare the two in constant time. `X-SplashSend-Event` names the event. **Delivery:** reply with any 2xx status to acknowledge a message. A message is tried up to three times: the second try comes 5 seconds after the first fails, and the third 15 seconds after the second. Each try waits up to 10 seconds for a reply. Redirects aren't followed, so a 3xx reply counts as a failure. Up to four messages are sent to an endpoint at once, so they can arrive out of order. **Pausing:** an endpoint is paused when every try has failed for 20 messages in a row and the first of those failures was at least a day ago. The account's owner and admins are emailed when that happens. In the endpoint's Deliveries log you can Redeliver one message, or click Send failed ones again to resend everything from the last 30 days that didn't get through. Delivery logs are kept for 30 days. **Email events:** every `email.*` event carries `emailSendId`, `contactId` and `email`, the address the email went to. `campaignId` is set for a campaign email, `dripStepId` for a drip email and `scheduledEmailId` for a Cruise email, and the other two are null. **Names:** every event about a person also carries their `firstName` and `lastName`, null when they have none. **Apps:** an app subscribes to these events with POST /webhooks rather than through Setup. See Webhook subscriptions. ## Errors Every error has the body `{"error": {"code": "...", "message": "..."}}`. The message says what to fix, in plain words. - 400 `validation_error`: A field is missing, or has a value the endpoint can't take. - 400 `invalid_request`: The body isn't valid JSON, or isn't the shape the endpoint expects. - 400 `read_only`: The data set is built in or read from a connected source, so it can't be changed this way. - 400 `limit_reached`: A cap was reached, such as 5,000 rows in a data set, 100 data sets, 500 lists in an account or 50 subscriptions for an app. - 400 `sender_not_verified`: A campaign can't be sent or scheduled until the account's sender email is verified in Setup. - 400 `design_not_ready`: The design can't be sent as it is, such as one without an unsubscribe link. The message says what to fix. - 400 `send_refused`: The campaign couldn't be sent, for the reason the message gives. - 401 `unauthorized`: The key or token is missing, wrong, expired or revoked, or the app was removed. - 403 `forbidden`: The key or token doesn't have the scope this request needs. - 403 `key_in_web_page`: A key made under Apps was sent from a web page, where anyone can copy it. Call SplashSend from a server, and make a new key for the app. - 403 `plan_limit_reached`: Your plan's people limit is reached, so a new person can't be added, or its sends for the month are used up. - 403 `not_open_yet`: The account isn't open for sending yet. - 404 `not_found`: The person, list, data set or row isn't in your account. - 409 `stopped_address`: The address is stopped because it bounced or was reported as spam, so it can't be subscribed. - 409 `sent_recently`: This design went, or is scheduled to go, to the same people within a day, so it wasn't sent again. - 409 `idempotency_in_progress`: A request with this Idempotency-Key is still running. Wait for the `Retry-After` header (5 seconds), then retry. - 413 `payload_too_large`: The body is over the size limit. - 422 `idempotency_key_reused`: This Idempotency-Key was already used for a different request. Send a new key. - 429 `rate_limited`: The key, or the public app on this account, made more than 100 requests in a minute. Wait for the `Retry-After` header (60 seconds), then retry. - 500 `internal_error`: Something went wrong on our side. ## Limits and retries 100 requests a minute for each key, and for each public app on each account. Over that: a 429 with `Retry-After: 60`. POST requests that add things accept an `Idempotency-Key` header: send a new one with each new request and the same one when you retry it, and nothing is done twice. ## Connecting a public app (OAuth 2.1 with PKCE) 1. Send the person to https://splashsend.com/oauth/authorize with `response_type=code`, `client_id`, `redirect_uri` (one you registered), `scope` (space-separated permissions), `state`, `code_challenge` and `code_challenge_method=S256`. 2. They choose Allow and come back to the `redirect_uri` with `code`, `state` and `iss`. Check the state. 3. POST https://splashsend.com/api/oauth/token, form-encoded: `grant_type=authorization_code`, `code`, `redirect_uri` and `code_verifier`, with the client ID and secret as HTTP Basic (or `client_id` and `client_secret` in the body). 4. You get `access_token` (an hour), `refresh_token` and `scope`. Refresh with `grant_type=refresh_token`; each refresh token works once and comes back new. 5. A 401 means the account removed the app or the token expired. Refresh, and if that fails, ask the person to connect again. 6. Revoke a token at https://splashsend.com/api/oauth/revoke. Everything is described at https://splashsend.com/.well-known/oauth-authorization-server. ## MCP servers - https://splashsend.com/api/mcp: SplashSend's AI assistant tools for one account, with a key or an access token. - https://splashsend.com/api/mcp/docs: this guide as tools (search it, describe an endpoint, check a request before sending it). No key needed. ## More - The API reference, OpenAPI 3.1: https://splashsend.com/api/openapi - The same, to read: https://splashsend.com/api/docs - Making an app: https://splashsend.com/dashboard/apps/developer