Delivery API reference
Written from the buyer side. What we post to your endpoint, every field it carries, what we expect back, how retries and signatures work, and the REST endpoints for pulling reports and pushing outcomes back.
How delivery works
Six conventions hold across everything on this page.
HTTPS and JSON
One POST per lead, over TLS, with a UTF-8 JSON body and a content type of application/json. Timestamps are RFC 3339 in UTC and phone numbers are E.164, both directions.
Credentials from onboarding
A signing secret, an API token, and optionally the bearer token you want us to present to your endpoint. There is no self-serve key page, because a delivery carries consumer contact data and a consent record.
The lead id is the key
Idempotency on delivery is the lead_id. It is also the key for credits, consent requests, dispositions, and support. Store it on your record before anything else.
Every delivery is signed
An HMAC over the timestamp header and the raw body, keyed with your signing secret. Verify against the raw bytes, compare in constant time, and reject stale timestamps.
At-least-once delivery
Failures and timeouts are retried on a backoff schedule with the same lead id and a higher attempt number. Build the consumer to be idempotent rather than assuming exactly-once.
One error envelope
Errors from the REST API carry a machine-readable type and code alongside the message and a request id. See the error format.
This page documents the shape of the contract. The host you call, the authoritative schemas, the full enumerated value sets, and your credentials are issued during onboarding.
Credentials come from onboarding
There is no self-serve key generator, and that is deliberate. Every payload on this page carries a real person, their phone number, and the record of what they agreed to.
- A signing secret. Used to verify that a delivery came from us and has not been replayed. Rotate it on request without interrupting delivery.
- An API token. Bearer auth for the buyer-facing endpoints, scoped to your account and restrictable to read-only.
- Your endpoint credential. If your endpoint expects a bearer token or a shared secret header, give it to us and we present exactly that on every post.
- Test traffic first. Sample leads travel the same path with
testset to true, so the mapping is proven before a live record moves. See test leads.
The request we make to your endpoint
A single POST, sent within seconds of the form submission. This is the whole delivery mechanism.
POST /webhooks/solved-marketing HTTP/1.1
Host: crm.example-agency.com
Content-Type: application/json
User-Agent: SolvedMarketing-Delivery/1
X-Solvedmarketing-Lead-Id: lead_01K5R7TQ3M9B
X-Solvedmarketing-Delivery: dlv_01K5R7TQ4F2C
X-Solvedmarketing-Attempt: 1
X-Solvedmarketing-Timestamp: 1789056000
X-Solvedmarketing-Signature: v1=6b41f0c8d2a7e93b5c
Authorization: Bearer BUYER_TOKEN_FROM_ONBOARDING
{
"lead_id": "lead_01K5R7TQ3M9B",
"program_id": "prog_fex_core",
"vertical": "final_expense",
"test": false,
"created_at": "2026-09-18T14:19:46Z",
"delivered_at": "2026-09-18T14:19:52Z",
"contact": {
"first_name": "Marjorie",
"last_name": "Alvarado",
"phone": "+14045550143",
"phone_type": "mobile",
"city": "Atlanta",
"state": "GA",
"postal_code": "30309",
"county": "Fulton",
"date_of_birth": "1957-04-02",
"age": 69,
"time_zone": "America/New_York"
},
"final_expense": {
"coverage_reason": "burial_and_final_costs",
"monthly_budget": "50_to_75",
"existing_coverage": false,
"tobacco": false,
"health_flags": ["none_reported"],
"beneficiary_intent": "adult_children"
},
"consent": {
"captured_at": "2026-09-18T14:19:44Z",
"ip_address": "198.51.100.37",
"page_url": "https://offer.example/fex/quote",
"disclosure_id": "disc_fex_2026_03",
"form_duration_seconds": 74
},
"source": {
"channel": "paid_social",
"campaign_ref": "fex-ga-q3",
"device": "mobile"
}
}
# what we want back
HTTP/1.1 200 OK
Content-Type: application/json
{
"received": true,
"lead_id": "lead_01K5R7TQ3M9B",
"your_record_id": "CRM-884213"
}
# a duplicate you already hold
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"received": true,
"duplicate": true,
"lead_id": "lead_01K5R7TQ3M9B"
}
# a rejection we should not retry
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"received": false,
"reason": "state_not_licensed",
"lead_id": "lead_01K5R7TQ3M9B"
}
A response body is optional and we do not require any particular shape. If you send one, your_record_id is stored against the lead so a later support conversation can be held in your identifiers rather than ours.
Request headers
Eight headers, four of which exist so you can deduplicate and verify without parsing the body.
| Header | What it carries |
|---|---|
Content-Type | Always application/json. The body is UTF-8 encoded JSON. |
User-Agent | Identifies the delivery client and its major version, for example SolvedMarketing-Delivery/1. |
Authorization | A bearer token you supply at onboarding, if your endpoint requires one. We send exactly what you give us. |
X-Solvedmarketing-Lead-Id | The lead id, duplicated out of the body so you can deduplicate without parsing. |
X-Solvedmarketing-Delivery | A unique identifier for this delivery attempt. Changes on every retry. |
X-Solvedmarketing-Attempt | The attempt number, starting at 1. Anything above 1 is a retry of a delivery you may already hold. |
X-Solvedmarketing-Timestamp | Unix seconds at signing time. Part of the signed payload; reject deliveries outside your tolerance window. |
X-Solvedmarketing-Signature | Version marker and HMAC over the timestamp and the raw body. See verifying a signature. |
Lead fields
Seven groups. The envelope, contact, consent, and source blocks are identical in every vertical; only the vertical block changes.
Envelope
Present on every lead, in every vertical. These are the fields your integration should key on.
| Field | Type | Description |
|---|---|---|
lead_id |
string | Stable identifier for the lead. The key for credits, consent requests, dispositions, and support. Store it. |
program_id |
string | The program the lead came from. One program is one vertical, one filter set, one delivery path, and one pacing configuration. |
vertical |
enum | One of medicare, life, or final_expense. Tells you which vertical block to read. |
test |
boolean | True for onboarding and regression traffic. Test leads are never billed and never count against a cap. |
created_at |
string | RFC 3339 UTC timestamp of the form submission. |
delivered_at |
string | RFC 3339 UTC timestamp of this delivery attempt. Subtract your first dial time from this to get speed to dial. |
Contact and identity
Identical across verticals. Nested under contact.
| Field | Type | Description |
|---|---|---|
first_name |
string | Given name as the prospect entered it. |
last_name |
string | Family name as the prospect entered it. |
phone |
string | E.164, for example +14045550143. Validated before delivery. |
phone_type |
enum | mobile, landline, or voip, from the validation run before delivery. |
email |
string | Optional. Omitted when the form did not collect it or the prospect declined. |
address_line1 |
string | Street address. Optional on programs that do not collect it. |
city |
string | City name. |
state |
string | Two-letter USPS code. |
postal_code |
string | Five-digit ZIP. |
county |
string | County name without the word county. Present because licensing and plan availability are frequently county-level. |
date_of_birth |
string | ISO 8601 date. Omitted on programs that collect an age band instead. |
age |
integer | Derived from date of birth at submission time, so your fit rules do not have to compute it. |
gender |
enum | Optional. Present only where the prospect supplied it. |
time_zone |
string | IANA zone derived from the address. Dial on this rather than on your office time zone. |
Medicare block
Nested under medicare, present when vertical is medicare.
| Field | Type | Description |
|---|---|---|
plan_interest |
enum | advantage, part_d, or supplement, captured at the form rather than guessed at on the call. |
currently_enrolled |
boolean | Whether the prospect says they already hold a Medicare plan. |
enrollment_period |
enum | aep, ma_oep, sep, or age_in. The period that makes this prospect eligible to move. |
turning_65 |
boolean | True for age-in demand. |
eligibility_date |
string | ISO 8601 date, present on age-in and special enrollment records where the prospect gave one. |
medicaid_flag |
boolean | Optional. Present only on programs that ask. |
Life block
Nested under life, present when vertical is life.
| Field | Type | Description |
|---|---|---|
product_interest |
enum | term or permanent. Separate programs, because they are separate prospects. |
coverage_amount |
enum | The face amount band the prospect asked about, so the agent opens with the right product. |
term_length |
enum | Present when the prospect named one, for example 20_year. |
tobacco |
boolean | Self-reported tobacco use. |
beneficiary_intent |
enum | Why they are shopping. The difference between a quote and a conversation. |
health_flags |
array | Self-reported condition flags, enough at the form to route to an agent who can place the case. |
Final expense block
Nested under final_expense, present when vertical is final_expense.
| Field | Type | Description |
|---|---|---|
coverage_reason |
enum | What the coverage is for, usually burial and end-of-life costs. |
monthly_budget |
enum | The monthly premium band the prospect had in mind, so the agent builds to budget rather than guessing. |
existing_coverage |
boolean | Whether the prospect already holds a policy. |
tobacco |
boolean | Self-reported tobacco use. |
health_flags |
array | Self-reported condition flags that predict whether a simplified issue product will actually place. |
beneficiary_intent |
enum | Who the prospect is trying to protect. |
Consent record
Nested under consent, identical across verticals. Captured at the form, because it cannot be reconstructed afterward.
| Field | Type | Description |
|---|---|---|
captured_at |
string | RFC 3339 UTC timestamp of the agreement itself. |
ip_address |
string | The address the submission came from. |
page_url |
string | The full URL of the page the prospect was on. |
disclosure_id |
string | Identifier for the exact, versioned disclosure block shown. This is the field to store. |
disclosure_text |
string | The language the prospect actually saw, not a template. |
user_agent |
string | The browser user agent string at submission. |
form_duration_seconds |
integer | How long the prospect spent on the form. |
Source and attribution
Nested under source. Deliberately coarse: enough to tune your own reporting, not the media buying detail.
| Field | Type | Description |
|---|---|---|
channel |
enum | The broad acquisition channel, for example paid_social or search. |
campaign_ref |
string | A stable reference for the campaign concept. Tell us when one outperforms on your floor. |
device |
enum | mobile, desktop, or tablet. |
landing_page_ref |
string | A stable reference for the landing page variant. |
Fields are added over time and are not removed without notice, so parse defensively and ignore keys you do not recognize. Absent optional fields are omitted rather than sent as null.
Expected responses
What each status code from your endpoint causes on our side.
| Your response | What happens |
|---|---|
200 OK | You accepted the lead. Nothing further happens. |
201 Created | You accepted the lead and created a record. Treated exactly like a 200. |
202 Accepted | You queued the lead for processing. Treated as accepted; we do not wait for the downstream result. |
409 Conflict | You already hold this lead_id. Recorded as delivered and not retried. The correct answer to a duplicate retry. |
4xx (other) | Treated as a failed delivery and retried. A persistent 4xx usually means credentials or a body your endpoint rejects, and we will contact you. |
5xx | Treated as a failed delivery and retried on the backoff schedule. |
Timeout | No response inside the response window is a failed delivery. Answer fast and do the work out of band. |
You are not billed for a lead we could not deliver. A program failing to deliver is something we raise with you rather than letting it quietly stop.
Retries and idempotency
Delivery is at least once, which is a design constraint rather than an apology. Build for it and duplicates stop being a category of bug.
- The lead id is the idempotency key. Every retry carries the same
lead_id. Key on it, and a retry becomes a no-op instead of a second record. - Attempts are numbered.
X-Solvedmarketing-Attemptstarts at 1 and increments. Anything above 1 is a delivery you may already hold. - Backoff, not a hammer. Retries are spaced on an increasing schedule so an endpoint recovering from an incident is not knocked back over.
- 409 stops the retries. Answering 409 for a lead id you already hold records the delivery as complete rather than continuing the schedule.
- Undeliverable leads are held. A lead that fails the full schedule is not discarded and not billed. See failed deliveries.
- Writes you make are idempotent too. Send
Idempotency-Keyon any POST to the REST API and a repeat returns the original response instead of creating a second resource.
Build the consumer this way
- Verify
- Check the signature against the raw bytes before you parse anything.
- Dedupe
- Look up the lead id. If you have it, answer 409 and stop.
- Accept
- Write the record, return a success status immediately.
- Then work
- Enrichment, scoring, routing, and notifications all happen after the response.
Verifying a signature
Four steps. The two that catch people are verifying against the raw bytes and rejecting stale timestamps.
- Read the raw body first. Capture the bytes exactly as received, before any JSON parsing. Reordering a key changes the bytes and breaks the comparison.
- Build the signed payload. The timestamp header, a period, then the raw body.
- Compare in constant time. A naive string comparison leaks timing information about the secret.
- Reject stale timestamps. Anything outside your tolerance window is a replay, even if the signature checks out.
# PHP
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_SOLVEDMARKETING_TIMESTAMP'];
$sig = $_SERVER['HTTP_X_SOLVEDMARKETING_SIGNATURE'];
if (abs(time() - (int) $ts) > 300) {
http_response_code(400);
exit; // outside the tolerance window
}
$expected = 'v1=' . hash_hmac(
'sha256',
$ts . '.' . $raw,
SOLVEDMARKETING_SIGNING_SECRET
);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$lead = json_decode($raw, true); // now it is safe
Webhooks and events
Register an HTTPS endpoint, subscribe it to the types you care about, and return a 2xx quickly. Deliveries are signed and retried the same way lead deliveries are.
| Event type | When it fires |
|---|---|
lead.delivered | A lead was posted to your endpoint. The data object is the full lead record. |
lead.accepted | Your endpoint answered with a success status. Carries the attempt count and the status code you returned. |
lead.rejected | Delivery failed after the full retry schedule, or your endpoint rejected the lead outright. The lead is held and not billed. |
lead.credited | A credit was applied to a lead. Carries the category and the decision, so your billing reconciliation can be automatic. |
disposition.received | We recorded an outcome you sent for a lead. Useful as an acknowledgement that the feedback loop is actually closed. |
The event envelope
Identical across event types. Only the data object changes shape.
{
"id": "evt_01K5R7TV81QD",
"type": "lead.credited",
"created_at": "2026-09-19T16:04:11Z",
"program_id": "prog_fex_core",
"data": {
"lead_id": "lead_01K5R7TQ3M9B",
"credit_id": "crd_01K5R7TV6W0M",
"category": "unreachable_phone",
"decision": "approved",
"submitted_at": "2026-09-19T09:52:03Z",
"decided_at": "2026-09-19T16:04:08Z"
}
}
Events are delivered at least once and are not ordered. Key on the event id, or on the lead id for lead events, and reconcile on the timestamps inside the payload rather than on arrival order. The signature scheme is the one described under verifying a signature.
The buyer-facing REST API
Bearer auth over HTTPS, JSON in and out, cursor pagination, idempotent writes. This is how you pull your own numbers and push outcomes back.
Reading leads, deliveries, and reports
Collections return data, has_more, and next_cursor. Cursors are opaque; do not construct them or assume they encode an offset.
| Endpoint | What it does |
|---|---|
GET /v1/leads | List delivered leads by date range, program, vertical, or delivery state. Cursor paginated. |
GET /v1/leads/{lead_id} | Fetch one lead exactly as it was delivered, including the consent record. |
GET /v1/deliveries | Delivery attempts with their timestamps, attempt counts, and the status your endpoint returned. |
GET /v1/programs | The programs on your account with their current caps, delivery windows, and live pacing state. |
GET /v1/reports/delivery | Delivered, accepted, rejected, and credited counts by program and day. |
GET /v1/reports/outcomes | Contact, appointment, and issued counts by program and day, computed from the dispositions you have sent. |
POST /v1/exports | Schedule a recurring export of leads and delivery records to an endpoint you name. Idempotent. |
Posting dispositions and credits back
Outcomes are what turn a lead program from a guess into something tunable on your traffic specifically. Send them natively from AgentTech Dialer or Solved Enroll, over these endpoints, or as a scheduled file if that is genuinely all your stack can do.
| Endpoint | What it does |
|---|---|
POST /v1/dispositions | Record an outcome against a lead id: the disposition, the timestamp, and the attempt count. Idempotent. |
POST /v1/dispositions/batch | The same thing for up to a day of outcomes in one request, for buyers exporting on a schedule. |
POST /v1/credits | Submit a credit request: lead id, category, and one sentence of detail. Idempotent. |
GET /v1/credits | List credit requests with their state and decision. |
POST /v1/programs/{id}/pause | Pause delivery on a program. No penalty, and resumable the same way. |
POST /v1/programs/{id}/resume | Resume a paused program. |
curl -X POST https://api.solvedmarket.ing/v1/dispositions \
-H "Authorization: Bearer $SOLVEDMARKETING_TOKEN" \
-H "Idempotency-Key: 4f2b7e10-5c33-4a71-9e08-1c6d" \
-H "Content-Type: application/json" \
-d '{"lead_id":"lead_01K5R7TQ3M9B",
"disposition":"appointment_set",
"attempts":2,
"first_attempt_at":"2026-09-18T14:20:31Z",
"occurred_at":"2026-09-18T14:41:02Z"}'
The disposition set we work from is small on purpose: contacted, not contacted, wrong number, not interested, appointment set, application started, application submitted, policy issued, and do not call. If your system already has its own set, send yours and we map it once. See the disposition set.
Rate limiting and errors
One ceiling, one error envelope, and status codes that mean what they usually mean.
Rate limiting
- The standard ceiling is 600 requests per minute per account on the buyer-facing endpoints.
- A 429 carries
Retry-Afterin seconds. Back off on it rather than retrying immediately. - Bulk reads are paginated rather than throttled harder. Pull by cursor, or schedule an export instead.
- Deliveries we make to you do not count against it. Those are paced by your caps and delivery windows.
- Higher limits are available on request. Describe the traffic shape, not just the peak number.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": {
"type": "invalid_request",
"code": "credit_window_closed",
"message": "That lead is outside the credit
submission window for this program.",
"param": "lead_id",
"request_id": "req_01K5R7TW2K8P"
}
}
| Status | Meaning |
|---|---|
400 Bad Request | The body could not be parsed, or a required parameter is missing or malformed. |
401 Unauthorized | No bearer token, an expired token, or a token that belongs to a different account. |
403 Forbidden | Authenticated but not permitted: a scope the token does not carry, or a program on another account. |
404 Not Found | No such lead, program, or credit, or it is outside the scope of this token. |
409 Conflict | The resource is not in a state that allows the operation, such as pausing a program that is already paused. |
422 Unprocessable Entity | Well formed and understood, but rejected by a rule: a disposition against a lead you were never delivered, or a credit outside the submission window. |
429 Too Many Requests | Rate limited. Retry-After carries the number of seconds to wait. |
5xx | Our fault. Safe to retry with the same idempotency key; a retried write will not duplicate. |
Quote the request_id when you report a problem. It is the fastest way for us to find the request in our logs.
FAQs
Delivery API questions
Where do credentials come from?
Onboarding, not a self-serve key page. You receive a signing secret for verifying deliveries, an API token for the buyer-facing REST endpoints, and, if your endpoint requires one, we store the bearer token you want us to present when we post to you. All three are scoped to your account and revocable on their own.
Do I have to use the REST API at all?
No. Plenty of buyers only ever receive posts at an endpoint and never call us. The REST API is for pulling delivery and outcome reports into your own reporting, posting dispositions back, and submitting credits programmatically. Buyers on the AgentTech Dialer or Solved Enroll paths get most of that without any code.
How is idempotency handled on delivery?
The lead id is the idempotency key. Every retry of a delivery carries the same lead id with an incrementing attempt header, so a consumer that keys on the lead id will never create two records. If you already hold the lead id, answering 409 tells us so and stops the retries.
What is the response window on a delivery?
Short enough that it should never be a design question: accept the body, return a success status, and do enrichment, scoring, or third-party lookups out of band. An endpoint that answers only after finishing all of its downstream work will be retried while it is still working, and will process the same lead twice.
Is this a specification I can generate a client from?
This page documents the shape of the contract: the request we make, the fields it carries, the responses we act on, the events, and the buyer-facing endpoints. The authoritative schemas, the full set of enumerated values for each field, and the host you call come with your credentials at onboarding.
What are the rate limits?
The standard ceiling on the buyer-facing REST API is 600 requests per minute per account, and a 429 carries a Retry-After header. Bulk reads are paginated rather than throttled harder, so pull by cursor or schedule an export instead. Higher limits are available on request; describe the traffic shape rather than just the peak number.
Do deliveries to my endpoint count against the rate limit?
No. The rate limit applies to requests you make to us. Deliveries we make to you are paced by your program caps and delivery windows, which are the controls that actually decide how much traffic your endpoint sees.
Something else? Contact us
Ready to build against it?
Onboarding issues the signing secret, the API token, and test leads through your endpoint in the same conversation.