Skip to main content

Lists and Gmail API

Every /api/v1/outbound/lists and /api/v1/gmail/accounts route with its query, body, answer, scope, errors and retries

This page covers two resources: outbound lists at https://app.makesmores.com/api/v1/outbound/lists and Gmail accounts at https://app.makesmores.com/api/v1/gmail/accounts. Paths below are relative to https://app.makesmores.com/api/v1. Send Authorization: Bearer <key> on every call and bodies as JSON with Content-Type: application/json.

note

Outbound lists are enabled in production. Gmail accounts aren't enabled yet, and Gmail accounts says what they answer until they are.

On each path the method and the query together pick one mode, and each parameter belongs to the mode it picks. A request that matches no mode answers 400 invalid_request before S'more reads the key, and costs nothing. While either product is closed, every request to its path gets the same plain text 404 page not found as a path S'more doesn't serve. API conventions covers these rules and what every route here shares: errors, caching, rate limits, billing, timeouts, retries and paging. Every route bills $0.001 per call.

Answers use snake_case, except upload history, which answers in camelCase. The JSON samples below are shortened, so read fields by name and expect more of them.

In the tables below, Authority is the role the operation checks. Which scopes a key can hold depends on the role of the person who made it; see Who can grant what.

Outbound lists​

A list is a CSV file imported once under a name. Which rows a list holds doesn't change after the import, and no route edits or deletes a list.

Method and queryScopeAuthorityDoes
GET /outbound/lists, optional limit and cursoroutbound:lists:readAny memberThe workspace's lists, newest first
GET /outbound/lists?id=outbound:lists:readAny memberOne list
GET /outbound/lists?id=&view=people, optional outcome, limit and cursoroutbound:lists:readAny member. Only owners and admins see full email addressesA list's rows in file order
GET /outbound/lists?view=uploadsoutbound:lists:readAny memberThe workspace's CSV upload history
POST /outbound/lists?action=previewoutbound:lists:writeOwner or adminWhat importing a CSV would do. It writes nothing
POST /outbound/lists?action=importoutbound:lists:writeOwner or adminImports a CSV as a new list. It sends nothing

Previews and imports send the file inside the JSON body as one string in csv. They don't take a multipart upload.

Preview a CSV​

POST /outbound/lists?action=preview takes:

FieldRules
modeRequired. contacts keys people by email or contact id. linkedin_profiles keys them by LinkedIn profile URL, and can add people known only by their profile
csvRequired. The whole file as one string, header row first. Up to 10 MiB, 20,000 rows and 500 columns. Blank rows don't count
mappingOptional. Each header of the file to one field, such as {"Email": "email"}. Without it, S'more uses a mapping saved earlier for the same headers, or guesses, and saved_mapping says which
file_nameOptional. Up to 255 characters
{
"mode": "linkedin_profiles",
"csv": "Name,Email,LinkedIn\nSam Lee,,https://www.linkedin.com/in/sam-lee/\nNobody,,https://example.com/nobody\nJo Park,jo@example.com,linkedin.com/in/jo-park\n",
"file_name": "people.csv"
}

The answer says what an import would do with each row, and nothing is saved:

{
"mode": "linkedin_profiles",
"file_name": "people.csv",
"header_key": "7de3fe024f4c834d9d8514db13865455edad57fa14a03f35e1bb7eebd914aae9",
"columns": [
{ "header": "Name", "example": "Sam Lee", "field": "full_name" },
{ "header": "Email", "example": "jo@example.com", "field": "email" },
{ "header": "LinkedIn", "example": "https://www.linkedin.com/in/sam-lee/", "field": "linkedin_url" }
],
"fields": ["email", "first_name", "last_name", "full_name", "title", "company", "company_domain", "account_id", "icp", "role", "linkedin_url", "contact_id", "outreach_status", "custom", "skip"],
"saved_mapping": false,
"mapping_error": "",
"rows_total": 3,
"counts": { "created": 2, "matched": 0, "updated": 0, "invalid": 1, "conflict": 0, "duplicate": 0 },
"issues": [{ "ordinal": 2, "outcome": "invalid", "reason": "invalid_profile" }],
"issues_truncated": false
}

columns[].field is the field each header would fill, and fields lists the fields a mapping can use. custom keeps a column on the contact under its header, and skip drops it. issues names the rows that wouldn't become members, by ordinal, the row's number in the file. issues_truncated is true when it left some out.

A mapping must name every header once, use only values from fields, and use each field other than custom and skip at most once. A contacts list needs email or contact_id mapped, and a linkedin_profiles list needs linkedin_url. When the mapping can't be used, the preview still answers 200, with the reason in mapping_error, such as missing_profile.

Import a list​

POST /outbound/lists?action=import takes:

FieldRules
modeRequired. As for the preview
csvRequired. As for the preview
mappingRequired. Copy it from the preview's columns, changing any field you want, as {"Name": "full_name", "Email": "email", "LinkedIn": "linkedin_url"}
nameRequired. 1 to 200 characters on one line
idempotency_keyRequired. 1 to 128 printable ASCII characters
file_nameOptional. Up to 255 characters

The answer is the new list:

{
"list": {
"id": "66666666-6666-4666-8666-666666666666",
"name": "Conference leads",
"mode": "linkedin_profiles",
"source": "csv",
"file_name": "people.csv",
"rows_total": 3,
"members": 2,
"counts": { "created": 2, "matched": 0, "updated": 0, "invalid": 1, "conflict": 0, "duplicate": 0 },
"created_by": "33333333-3333-4333-8333-333333333333",
"created_at": "2026-10-30T12:00:00Z"
}
}

Each row gets one outcome:

OutcomeMeaning
createdA new contact in the workspace
matchedA contact the workspace already had, left as it was. A contact found by its LinkedIn profile alone is never changed
updatedA contact the workspace already had, now with the row's fields saved on it
invalidNot imported. reason is missing_key, invalid_profile or row_id_too_long
conflictNot imported, because the row disagrees with contacts already saved. reason is ambiguous_profile, email_in_use, contact_id_in_use, email_mismatch or profile_mismatch
duplicateNot imported, because an earlier row names the same person. duplicate_of is that row's ordinal, and reason is duplicate_in_file or same_contact

Rows that are created, matched or updated are the list's members, and members counts them. A row with an email address that isn't valid can still be imported without the address, with reason invalid_email.

An import writes the workspace's contacts, the list, its rows and an audit log entry, and nothing else. It sends nothing and queues nothing, and it doesn't appear in upload history. An email address in a list isn't permission to email that person: has_email only says the contact has one.

The same idempotency_key with the same mode, name, file_name, csv and mapping answers the same list again and writes nothing. The same key with any of those changed answers 409 idempotency_conflict. A new key imports a second list.

Read lists​

GET /outbound/lists answers a page of lists, newest first, in items. limit is 1 to 100 lists a page, 50 when you leave it out. Send next_cursor back as cursor, and stop when there's none:

{
"items": [
{
"id": "66666666-6666-4666-8666-666666666666",
"name": "Conference leads",
"mode": "linkedin_profiles",
"source": "csv",
"file_name": "people.csv",
"rows_total": 3,
"members": 2,
"counts": { "created": 2, "matched": 0, "updated": 0, "invalid": 1, "conflict": 0, "duplicate": 0 },
"created_by": "33333333-3333-4333-8333-333333333333",
"created_at": "2026-10-30T12:00:00Z"
}
]
}

GET /outbound/lists?id= answers the same shape with only that list in items.

Read a list's rows​

GET /outbound/lists?id=&view=people answers the list's rows in file order. outcome keeps only some outcomes, comma separated, such as outcome=invalid,duplicate. limit and cursor work as for lists, and a cursor only works with the list and the outcome filter it came from. For the list imported above:

{
"items": [
{
"ordinal": 1,
"outcome": "created",
"reason": "",
"profile_key": "sam-lee",
"contact_id": "55555555-5555-4555-8555-555555555555",
"row_id": "55555555-5555-4555-8555-555555555555",
"name": "Sam Lee",
"linkedin_url": "https://www.linkedin.com/in/sam-lee/",
"email": "",
"has_email": false,
"linkedin_eligible": true
},
{ "ordinal": 2, "outcome": "invalid", "reason": "invalid_profile", "duplicate_of": null, "contact_id": null, "has_email": false, "linkedin_eligible": false },
{
"ordinal": 3,
"outcome": "created",
"reason": "",
"duplicate_of": null,
"supplied_row_id": "",
"profile_key": "jo-park",
"contact_id": "77777777-7777-4777-8777-777777777777",
"row_id": "77777777-7777-4777-8777-777777777777",
"name": "Jo Park",
"title": "",
"company": "",
"linkedin_url": "linkedin.com/in/jo-park",
"email": "j…@example.com",
"has_email": true,
"linkedin_eligible": true
}
]
}

A member has contact_id and row_id. A row that isn't a member carries only its ordinal, outcome, reason and what the file supplied. A member of the workspace sees each email address shortened to its first letter and domain, as above, and owners and admins see it in full. linkedin_eligible says whether the contact's LinkedIn profile can be used for LinkedIn outreach.

Upload history​

GET /outbound/lists?view=uploads answers the CSV uploads made in the S'more app, in camelCase. Lists imported over the API don't appear here:

{
"items": [
{ "id": "88888888-8888-4888-8888-888888888888", "kind": "contacts", "name": "people", "createdAt": "2026-10-30T12:00:00Z", "rows": 3, "created": 2, "updated": 0, "skipped": 1 }
],
"totals": { "companies": 2, "people": 40, "withEmail": 31, "replies": 4 }
}

It takes no paging parameters.

List errors​

StatusCodeMeaning
400invalid_requestA field doesn't fit, or a bad id, limit, outcome or cursor. The message can name a reason in brackets, such as mode, name, idempotency_key, no_header, empty_header, csv_read or too_many_columns
400mapping_incompleteThe import's mapping can't be used. The reason is in the message, such as (missing_profile), not in a field of its own
403admin_requiredOnly owners and admins preview and import
404not_foundNo list with that id in this workspace
409idempotency_conflictThat key already names a different import
409import_runningAnother import into this workspace is running. Try again when it finishes
413file_too_largeThe CSV is over 10 MiB or 20,000 rows, or the whole body is over 11 MiB
429rate_limitedOver a limit on Rate limits, such as the one on previews. Wait for Retry-After

Reading lists and rows is safe to repeat. A preview writes nothing, but each one counts toward the limit on Rate limits. Previews and imports have a 3 minute deadline. If an import times out or its answer is lost, send the identical body again with the same idempotency_key. It answers the list if the first import saved it, or imports it now if it didn't. While the first is still running, it can answer 409 import_running, so wait and send it again.

Gmail accounts​

note

Gmail accounts aren't enabled in production yet. Until they are, every request to /gmail/accounts answers 404 with the plain text 404 page not found.

A mailbox is one person's own Gmail, connected so that S'more can send email from it. S'more asks Google for permission to send from that address, and can't read its mail. The person who connects a mailbox owns it, and only they can send from it, test it or rename it.

Method and queryScopeAuthorityDoes
GET /gmail/accountsgmail:readAny member. Members see each mailbox's state, not its addressThe workspace's mailboxes, and whether this workspace can connect one
POST /gmail/accountsgmail:writeOwner or adminStarts connecting the caller's own Gmail, and answers a consent link
POST /gmail/accounts?id=&action=reconnectgmail:writeOwner or admin who owns the mailboxStarts signing in to the same Google account again
POST /gmail/accounts?id=&action=testgmail:sendOwner or admin who owns the mailboxSends a test email from the mailbox to its own address
PATCH /gmail/accounts?id=gmail:writeOwner or admin who owns the mailboxChanges the name on the mailbox's emails
DELETE /gmail/accounts?id=gmail:writeThe mailbox's owner, or any owner or adminDisconnects the mailbox for good

id is a mailbox's id from GET /gmail/accounts. There's no mode that reads one mailbox, because the list returns them all.

Read mailboxes​

GET /gmail/accounts takes no query:

{
"available": "connect",
"app_verified": false,
"can_connect": true,
"start_url": "/api/workspaces/11111111-1111-4111-8111-111111111111/integrations/gmail/start",
"mailboxes": [
{
"id": "44444444-4444-4444-8444-444444444444",
"email": "alex@example.com",
"display_name": "Alex at Example",
"owner_user_id": "33333333-3333-4333-8333-333333333333",
"owner_name": "Alex Kim",
"is_mine": true,
"status": "connected",
"status_reason": "",
"paused_until": null,
"connected_at": "2026-10-28T12:00:00Z",
"last_sent_at": "2026-10-30T11:00:00Z",
"revision": 2
}
],
"handoff": false,
"linkedin_senders": []
}

available is connect when this workspace can connect Gmail, and coming_soon when it can't yet. can_connect is true for an owner or admin in a workspace that can connect. start_url is the S'more app's own link for that, so over the API start with POST /gmail/accounts instead. A member gets email and display_name empty on every mailbox.

status is connected, needs_sign_in, blocked, paused or suspended. A pause whose paused_until has passed reads as connected. revision is what a rename must send back. A disconnected mailbox leaves the list.

Connect a mailbox​

POST /gmail/accounts takes no body, or {}. The answer is Google's consent link and the time it runs out, 10 minutes later:

{
"authorization_url": "https://accounts.google.com/o/oauth2/auth?client_id=...&state=...",
"expires_at": "2026-10-30T12:10:00Z"
}

Nothing is connected yet. The person whose key started it opens authorization_url in a browser where they're signed in to S'more, and gives consent at Google before expires_at. Google then sends the browser back to Settings in S'more, and the mailbox appears on GET /gmail/accounts with is_mine true. A link opened by anyone else connects nothing.

When a mailbox shows needs_sign_in, its owner sends POST /gmail/accounts?id=&action=reconnect and finishes in the browser the same way, signed in to the same Google account as before.

Send a test email​

POST /gmail/accounts?id=&action=test sends one email from the mailbox to its own address. With no body, it sends S'more's fixed test message. The answer is 204 with no body once Gmail confirms it.

Each person can send 3 test emails an hour, and tests sent from the S'more app count too. The fourth answers 409 test_limit, not 429.

The body can instead carry a draft of a LinkedIn follow-up email, with subject, body, calendar_url, signature and an optional contact_id to write it for that contact. S'more reads that body only while LinkedIn email is open, and otherwise ignores it and sends the fixed message. A draft S'more can't send as written answers 400 invalid_email, with field naming the part at fault, and doesn't count toward the 3:

{ "code": "invalid_email", "field": "calendar_url", "message": "Add a calendar link that starts with https://.", "runsRemaining": 0 }

A test that answers 502 send_unknown may or may not have arrived. S'more never sends it again on its own, so check the mailbox's Sent folder before you send another.

Rename a mailbox​

PATCH /gmail/accounts?id= takes:

FieldRules
display_nameRequired. The name on the mailbox's emails. Up to 100 characters on one line
expected_revisionRequired. The mailbox's revision from GET /gmail/accounts

The answer is the mailbox as the list shows it, with a new revision. A stale revision answers 409 revision_conflict, so read the list again and send the new one.

Disconnect a mailbox​

DELETE /gmail/accounts?id= takes no body and answers:

{ "mailbox_id": "44444444-4444-4444-8444-444444444444", "status": "disconnected" }

Disconnecting can't be undone. LinkedIn email set to send from the mailbox turns off, and emails waiting to send from it are cancelled. S'more then asks Google to remove its access, unless the same Google account is still connected in another mailbox. Disconnecting again answers the same. To use the address again, connect it as a new mailbox.

The mailbox's owner can disconnect it, and so can any owner or admin. Another member gets 403 mailbox_owner_required.

Workspaces that can't connect yet​

Once Gmail accounts are enabled, Gmail can still be limited to some workspaces. In a workspace that can't use it:

  • GET /gmail/accounts answers 200 with available coming_soon, can_connect false and start_url empty.
  • Connect, reconnect and rename answer an owner or admin with 404 not_found in JSON.
  • The test email answers an owner or admin with 404 and the plain text 404 page not found.
  • Disconnect works as it does everywhere.

Gmail errors​

StatusCodeMeaning
400invalid_requestA bad id on reconnect or rename, a name that doesn't fit, or a test body that isn't valid JSON with only the fields above
400invalid_emailThe test draft can't be sent as written. field names the part
403admin_requiredOnly owners and admins can do this
403mailbox_owner_requiredOnly the person who connected this Gmail can do this
404not_foundThis workspace can't connect Gmail yet. See Workspaces that can't connect yet
404mailbox_not_foundNo connected mailbox with that id in this workspace. On the test email and disconnect, that includes an id that isn't valid
404None, the body is the plain text 404 page not foundThe test email in a workspace that can't connect yet
409revision_conflictThe mailbox changed since you read it
409test_limitThis person sent 3 test emails in the last hour
409mailbox_not_connectedThe mailbox needs attention before it can send. Check its status
409needs_sign_inGoogle stopped S'more's access. Nothing was sent. The owner reconnects
409blockedThe Google Workspace admin isn't letting S'more send from this address. Nothing was sent
409gmail_pausedGmail paused sending from this account for a while. Nothing was sent. Retry-After says how long when Gmail gave a time
409gmail_rate_limitedGmail asked S'more to slow down. Nothing was sent. Retry-After says how long when Gmail gave a time
429rate_limitedOver a limit on Rate limits, such as the per-IP burst limit. Wait for Retry-After
502send_unknownS'more can't tell whether Gmail sent it. Check the Sent folder before you try again
502gmail_refusedGmail refused the email. Nothing was sent
503gmail_unavailable, limits_unavailableTry again shortly

Reading mailboxes and disconnecting are safe to repeat. A connect that isn't finished in time connects nothing, so start a new one. Recover from a failed or uncertain write lists what to read and what to do for each state.