Home Services — Server-to-server
Download Markdown · Download PDF
Home Service Leads · API v1 · Server-to-server
Section titled “Home Service Leads · API v1 · Server-to-server”| Specification version | 1.0 |
| Last updated | 2026-09-15 |
| Audience | Publishers posting home-service leads from their own backend |
| Support | info@freshwaymedia.com |
If you post from the consumer’s browser, use the browser-to-server specification instead. Personal-loan posting from a backend uses
POST /v1/personal-loans/leads.
1. Overview
Section titled “1. Overview”Freshway Media accepts home-service leads over a JSON REST API. You submit a single consumer application per request from your server; we validate it, run compliance and fraud checks, offer it to our buyer network, and return a decision synchronously in the HTTP response.
A request that returns 200 OK has been processed, not necessarily purchased. Read the
status field to determine the outcome.
We do not support a ping/post flow. There is no separate price-quote call: one request submits the lead and returns the decision.
Never put a secret key in a browser, mobile app, query string, URL path, or request body.
Getting started
Section titled “Getting started”- Request sandbox credentials from info@freshwaymedia.com. You will receive a secret key and your campaign names.
- Build and post a request against the sandbox base URL using the example in section 5.
- Confirm your handling of
accepted,declined, andduplicateoutcomes, and of validation errors. Sandbox outcomes are not deterministic; handle all threestatusvalues. See section 10. - Request production credentials. Sandbox keys do not work in production.
2. Endpoints
Section titled “2. Endpoints”| Environment | Base URL |
|---|---|
| Sandbox | https://sandbox-api.freshwayleads.com |
| Production | https://api.freshwayleads.com |
| Operation | Method | Path |
|---|---|---|
| Submit a home-service lead | POST |
/v1/home-services/leads |
TLS 1.2 or higher is required. Plain HTTP requests are rejected; they are not redirected.
3. Authentication
Section titled “3. Authentication”Present your secret key as a bearer token:
Authorization: Bearer <your-secret-key>| Key type | Prefix | Where it may be used | Secret? |
|---|---|---|---|
| Secret, production | fw_live_sk_ |
Server-side only | Yes |
| Secret, sandbox | fw_test_sk_ |
Server-side only | Yes |
Your account and entitled trades are identified by the key. Do not send an affiliate identifier in the request body; it is ignored.
A secret key can only submit leads. It cannot read leads, reporting, or account data.
Secret keys must never appear in client-side code, mobile applications, browser JavaScript, query strings, URL paths, or request bodies. If a secret key is exposed, contact us for immediate rotation.
Do not send an Origin header. A secret key presented with Origin is rejected with 403. That
combination means the secret has been embedded in client-side code and must be rotated.
A missing, malformed, unknown, inactive, or revoked key, or a key belonging to the other
environment, is rejected with 401. Inactive and revoked keys are indistinguishable from an
unknown key in the response.
4. Request headers
Section titled “4. Request headers”| Header | Required | Value |
|---|---|---|
Authorization |
Required | Bearer <your-secret-key> — see section 3 |
Content-Type |
Required | application/json; charset=utf-8 |
Accept |
Recommended | application/json |
X-Request-Id |
Optional | Your own correlation id. Echoed back in the response and in our logs. |
Idempotency-Key |
Reserved | Accepted and ignored. Do not rely on it. See section 9. |
5. Request body
Section titled “5. Request body”The payload is a flat JSON object using camelCase field names.
Conventions
Section titled “Conventions”| Concern | Rule |
|---|---|
| Character encoding | UTF-8 |
| Field naming | camelCase, case-sensitive |
| Enumerated values | Lower camelCase string values, case-sensitive. Never numeric codes. |
| Booleans | JSON true / false. Not "1", "Y", or "true". |
| Numbers | JSON numbers. Not quoted strings. No currency symbols, thousands separators, or units. |
| Money | US dollars and cents throughout. Never cents-only, and never with a currency symbol or thousands separator. Amounts we return ( payout) are a decimal string with exactly two places: "42.50" means $42.50. Parse it into a decimal type, not a float.Amounts you send are plain JSON numbers and may include cents: 210 and 210.47 are both valid. More than two decimal places is rejected rather than rounded. |
| Timestamps | RFC 3339 with a timezone. Prefer UTC with a Z suffix and millisecond precision: 2026-09-01T14:32:07.512Z. A numeric offset (+00:00) is also accepted, and fractional seconds are optional. Locale formats such as 01/09/2026 are rejected. |
| Empty values | Omit optional fields you have no value for, or send null. After trimming, an empty string on an optional field is treated as omitted. An empty string on a required field fails as required. |
| Unknown fields | Ignored, so we can add fields without breaking you. Do not rely on them being stored. |
| Trimming | Leading and trailing whitespace is trimmed from all string fields before validation. |
Field reference
Section titled “Field reference”Required values: Required always; Conditional required only for certain trades (see the
Required cell for the condition); Recommended optional but materially improves acceptance
rate and price; Optional accepted and passed through.
| Field | Type | Required | Constraints / allowed values | Description |
|---|---|---|---|---|
trade |
string (enum) | Required | windowsroofingplumbingelectricalbathroomkitchenguttersheatingsidingflooringhomeSecuritylawnCaremoldpaintingpestControlwaterDamageairConditioning |
The home-service trade the consumer is requesting. Determines which conditional fields apply and which buyers the lead is offered to. |
campaignName |
string | Required | Max 200 chars. Must match one of your active campaign names, which we give you with your keys. Matched ignoring case; surrounding whitespace is trimmed. Do not reuse a campaign name on /v1/personal-loans/leads. |
Which of your campaigns this lead belongs to. Determines the commission terms the lead is paid on, so it is required on every request and there is no default, even if you have only one campaign. A name we do not recognize as yours is rejected with 400; the lead is not processed. |
subId |
string | Optional | Max 50 chars | Your own traffic source or sub-affiliate identifier, free-form and not validated. This is for your own reporting breakdowns and does not affect attribution or payment: that is campaignName. |
submittedAt |
string (date-time) | Recommended | RFC 3339 with a timezone. Must not be more than 24 hours in the past or more than 5 minutes in the future. | When the consumer submitted the form on your property. Defaults to our receipt time if omitted. |
firstName |
string | Required | 1–50 chars. Letters, spaces, hyphens, and apostrophes. | Consumer’s first name. |
lastName |
string | Required | 1–50 chars. Letters, spaces, hyphens, and apostrophes. | Consumer’s last name. |
email |
string (email) | Required | Max 254 chars. Valid addressable mailbox. | Consumer’s email address. |
phone |
string | Required | 10-digit NANP (2051234567) or E.164 (+12051234567). Both forms are accepted. |
Consumer’s primary contact number. Mobile numbers are strongly preferred. |
address1 |
string | Required | 1–100 chars | Street address of the property to be serviced. |
address2 |
string | Optional | Max 100 chars | Unit, suite, or apartment. |
city |
string | Required | 1–50 chars | City of the service address. |
state |
string | Required | Exactly 2 chars. Uppercase USPS state or territory code, e.g. TX. |
State of the service address. |
postalCode |
string | Required | ^[0-9]{5}$ |
5-digit ZIP code of the service address. ZIP+4 is not accepted; send the first five digits only. |
isHomeOwner |
boolean | Required | true / false |
Whether the consumer owns the property. Non-owners are accepted but decline at a high rate; see notHomeOwner in section 6. |
propertyType |
string (enum) | Recommended | residentialcommercialmultiUnitmobileHome |
Type of property to be serviced. |
utilityProvider |
string | Optional | Max 50 chars | Consumer’s utility provider, e.g. Reliant Energy. |
averageMonthlyUtilityBill |
number | Optional | 0–99999 USD per month. Cents allowed, max 2 decimal places. | Consumer’s typical monthly utility bill. |
annualIncome |
number | Optional | 0–99999999 USD per year. Cents allowed, max 2 decimal places. | Consumer’s self-reported annual household income. |
projectType |
string (enum) | Recommended | replacementrepairnewInstall |
Nature of the work requested. |
projectStatus |
string (enum) | Recommended | planningAndBudgetingreadyToHire |
How far along the consumer is in their decision. |
projectTimeline |
string (enum) | Recommended | flexiblewithinOneWeekoneToTwoWeeksmoreThanTwoWeeks |
When the consumer wants work to begin. |
projectDescription |
string | Optional | Max 1000 chars | Consumer’s own description of the project. Passed to the buyer verbatim. Do not include phone numbers, email addresses, or other contact details. |
wallRemovalRequired |
boolean | Optional | true / false |
Whether the project requires removing walls. |
windowCount |
string (enum) | Conditional — required when trade is windows |
onetwothreeToFivesixToNinetenPlusother |
Number of windows in scope. |
roofingType |
string (enum) | Conditional — required when trade is roofing |
cedarmetalshinglesslatetileflatother |
Existing or desired roof material. |
sidingType |
string (enum) | Conditional — required when trade is siding |
vinylstuccofibercementwoodmetalother |
Existing or desired siding material. |
heatingType |
string (enum) | Conditional — required when trade is heating |
boilerradiatorelectricfurnacegasHeatheatPumpoilHeatother |
Heating system in scope. |
airConditioningType |
string (enum) | Conditional — required when trade is airConditioning |
centralAirairDuctscommercialCoolingductlessActhermostatsother |
Cooling system in scope. |
consent |
boolean | Required | Must be true. A lead the consumer did not consent to is rejected with 400. |
The consumer gave express written consent to be contacted about this request, including by phone and SMS. |
consentLanguage |
string | Required | 1–1000 chars | The verbatim disclosure text displayed to the consumer at the point of submission. Retained as your compliance record. |
consentAt |
string (date-time) | Recommended | RFC 3339 with a timezone. Must not be later than submittedAt. |
When the consumer accepted the disclosure. |
trustedFormCertificateUrl |
string (url) | Optional | Max 255 chars. When present: absolute https URL on cert.trustedform.com, with a non-empty path. |
TrustedForm certificate for this submission. Omit if you do not have one. When present, must be a well-formed https://cert.trustedform.com/... URL. consentNotVerified may be returned in a later revision; see section 6. |
landingPageUrl |
string (url) | Required | Max 2048 chars. Absolute https URL. A page that collects consumer PII must be served over TLS. |
The page on which the consumer submitted the form. Must be the actual form page, not your domain root. |
ipAddress |
string (ip) | Required | Valid IPv4 or IPv6 address. Must be the consumer’s public address, not your server’s. | Consumer’s IP address at submission. Used for fraud and geography checks. Omitting it is a 400, not a silent skip. |
userAgent |
string | Required | 1–500 chars | Consumer’s browser user-agent string at submission. |
Validation strictness
Section titled “Validation strictness”Beyond the constraints above we apply additional screening to email, phone, and
trustedFormCertificateUrl — for example rejecting undeliverable mailboxes, disposable and
role-based email domains, and non-dialable number ranges. This screening tightens over time
within v1 and is not exhaustively enumerated here. Do not build logic that depends on a
malformed value being accepted today.
Example request
Section titled “Example request”POST /v1/home-services/leads HTTP/1.1Host: sandbox-api.freshwayleads.comAuthorization: Bearer fw_test_sk_1a2b3c4d5e6f7890Content-Type: application/json; charset=utf-8Accept: application/json{ "trade": "windows", "campaignName": "HS-ACME-01", "subId": "spring-promo-42", "submittedAt": "2026-09-01T14:32:07.512Z", "firstName": "Test", "lastName": "McTester", "email": "test@notarealemailaddress.com", "phone": "+12051234567", "address1": "123 Main St", "address2": "Apt 4B", "city": "Dallas", "state": "TX", "postalCode": "75201", "isHomeOwner": true, "propertyType": "residential", "utilityProvider": "Reliant Energy", "averageMonthlyUtilityBill": 210.47, "annualIncome": 80000, "projectType": "replacement", "projectStatus": "readyToHire", "projectTimeline": "withinOneWeek", "projectDescription": "Six drafty single-pane windows on the north side of the house.", "wallRemovalRequired": false, "windowCount": "sixToNine", "consent": true, "consentLanguage": "By clicking Submit I agree to be contacted by up to four home improvement professionals at the phone number provided, including by automated technology and SMS, about my project. Consent is not a condition of purchase.", "consentAt": "2026-09-01T14:32:05.980Z", "trustedFormCertificateUrl": "https://cert.trustedform.com/0f7a5c1e9b3d4a628f10c2e5b7d9a3f18c604e2b", "landingPageUrl": "https://yourlandingpage.com/windows/quote", "ipAddress": "203.0.113.45", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"}6. Successful response
Section titled “6. Successful response”A processed lead returns 200 OK with Content-Type: application/json.
Response headers
Section titled “Response headers”| Header | Description |
|---|---|
X-Request-Id |
Correlation id for this request. Echoes your value if you supplied one. Quote it in support requests. |
X-RateLimit-Limit |
Your request ceiling for the current window. |
X-RateLimit-Remaining |
Requests left in the current window. |
X-RateLimit-Reset |
Unix epoch seconds at which the window resets. |
Response body
Section titled “Response body”| Field | Type | Always present | Description |
|---|---|---|---|
leadId |
string | Yes | Freshway’s identifier for this lead. An opaque string, at most 32 characters. Store it and quote it in support requests. Do not parse it or infer anything from its format, which may change. |
status |
string (enum) | Yes | accepted — a buyer purchased the lead.declined — processed but not purchased.duplicate — reserved; not currently returned. See section 9. |
receivedAt |
string (date-time) | Yes | When we received the request. RFC 3339 UTC. |
completedAt |
string (date-time) | Yes | When we finished processing. RFC 3339 UTC. |
redirectUrl |
string (url) | Only when status is accepted |
Buyer-supplied URL for the consumer. Absent, not empty, when there is nothing to redirect to. See below. |
payout |
string (decimal) | Only when status is accepted |
What this lead earned you, in US dollars and cents, always to two places: "42.50". "0.00" means the lead sold for nothing. |
declineReason |
string (enum) | Only when status is declined |
Machine-readable reason. See table below. |
declineMessage |
string | Only when status is declined |
Human-readable explanation, for your logs and dashboards. Do not branch on this text. |
originalLeadId |
string | Only when status is duplicate |
The leadId of the earlier submission. |
Successful responses carry no errors field. Errors appear only on 4xx and 5xx responses,
in the format described in section 7.
Using redirectUrl
Section titled “Using redirectUrl”Return it to your own page and navigate the consumer there. This is the buyer’s own confirmation or offer page and is the intended consumer experience.
When the field is present, always send the consumer. Buyers treat redirect rate as a quality signal; a low rate costs you coverage even though the lead already sold.
Decline reasons
Section titled “Decline reasons”declineReason |
Meaning | Should you retry? |
|---|---|---|
noBuyerMatch |
No buyer in the network purchased the lead. This is the general case. | No. |
notHomeOwner |
The consumer does not own the property, and non-owner leads are declined at a high rate. | No. |
failedFraudChecks |
The lead did not pass our fraud screening. | No. |
outOfCoverageArea |
No buyer services that postal code for that trade. | No. |
consentNotVerified |
The TrustedForm certificate could not be claimed or did not match the submission. | No. Fix your certificate capture. |
belowMinimumPrice |
No buyer bid at or above the floor price for this lead. | No. |
A trade your account is not entitled to post is not a decline. It returns 403 with
type: forbidden, because the lead was never processed and the remedy is a conversation with us
rather than a change to your payload. See section 7.
outOfCoverageArea, consentNotVerified, and belowMinimumPrice are not currently returned.
We return noBuyerMatch instead. Handle all six so you do not need to change anything when they
start appearing.
Example — accepted
Section titled “Example — accepted”{ "leadId": "01K4A7XJ2P8Q3R5S6T7V8W9XYZ", "status": "accepted", "redirectUrl": "https://buyer.example.com/offers/12345", "payout": "42.50", "receivedAt": "2026-09-01T14:32:08.104Z", "completedAt": "2026-09-01T14:32:09.016Z"}Example — declined
Section titled “Example — declined”{ "leadId": "01K4A7XJ7C2D4E6F8G0H1J2K3M", "status": "declined", "declineReason": "noBuyerMatch", "declineMessage": "No buyer in the network purchased the lead.", "receivedAt": "2026-09-01T14:32:08.104Z", "completedAt": "2026-09-01T14:32:08.755Z"}Example — duplicate
Section titled “Example — duplicate”{ "leadId": "01K4A7XKB5N6P7Q8R9S0T1U2V3", "status": "duplicate", "originalLeadId": "01K4A7XJ2P8Q3R5S6T7V8W9XYZ", "receivedAt": "2026-09-01T14:35:11.220Z", "completedAt": "2026-09-01T14:35:11.298Z"}7. Error responses
Section titled “7. Error responses”Errors return Content-Type: application/problem+json and follow
RFC 9457 Problem Details.
| Field | Type | Description |
|---|---|---|
type |
string (uri) | Stable identifier for the error class. Branch on this, not on status, title, or detail. |
title |
string | Short human-readable summary of the error class. |
status |
integer | HTTP status code, repeated for convenience. |
detail |
string | Human-readable explanation specific to this occurrence. |
instance |
string | Path of the request that failed. |
requestId |
string | Correlation id. Matches the X-Request-Id response header. |
errors |
array | Field-level failures. Present on validation errors only. |
errors[].field |
string | Name of the offending request field. |
errors[].code |
string | Machine-readable failure code: required, invalidFormat, tooLong, notAllowedValue, outOfRange. |
errors[].message |
string | Human-readable description of the field failure. |
Status codes
Section titled “Status codes”Six codes, each with distinct handling. Where two conditions call for the same action from you,
they share a status code and are distinguished by type.
| Status | type |
When | Retry? |
|---|---|---|---|
200 OK |
— | Lead processed. Read status for the outcome. |
n/a |
400 Bad Request |
.../malformed-request |
Body is not valid JSON, or Content-Type is wrong. |
No — fix the request. |
400 Bad Request |
.../validation-failed |
Body is valid JSON but one or more fields fail validation. errors is populated. |
No — fix the fields. |
401 Unauthorized |
.../unauthenticated |
Missing, malformed, unknown, inactive, or revoked key, or a key that belongs to the other environment. | No — fix credentials. |
403 Forbidden |
.../forbidden |
Key is valid but not entitled to post this trade; or a secret key was presented with an Origin header. |
No — contact us, except for an Origin header on a secret key, which requires rotating the key. |
429 Too Many Requests |
.../rate-limited |
Rate limit exceeded. Honor Retry-After. |
Yes, after the delay. |
500 Internal Server Error |
.../internal-error |
Unexpected failure on our side. | Yes, with backoff. |
Any 4xx means the lead was not processed and never will be. Do not resubmit the same payload
unchanged. A 429 is the exception: the payload was never looked at, so resubmit it after the
delay.
Example — validation failure
Section titled “Example — validation failure”HTTP/1.1 400 Bad RequestContent-Type: application/problem+jsonX-Request-Id: 9c1f4b7a-5d2e-4a83-b6c9-70e1f2a3d4b5{ "type": "https://docs.freshwayleads.com/errors/validation-failed", "title": "One or more fields failed validation.", "status": 400, "detail": "The lead was not processed. Correct the fields listed in 'errors' and submit a new request.", "instance": "/v1/home-services/leads", "requestId": "9c1f4b7a-5d2e-4a83-b6c9-70e1f2a3d4b5", "errors": [ { "field": "windowCount", "code": "required", "message": "windowCount is required when trade is 'windows'." }, { "field": "phone", "code": "invalidFormat", "message": "Expected a 10-digit NANP number or an E.164 number." }, { "field": "consent", "code": "notAllowedValue", "message": "consent must be true. Leads without express written consent are not accepted." } ]}Example — rate limited
Section titled “Example — rate limited”HTTP/1.1 429 Too Many RequestsContent-Type: application/problem+jsonRetry-After: 24X-RateLimit-Limit: 100X-RateLimit-Remaining: 0X-RateLimit-Reset: 1788192000{ "type": "https://docs.freshwayleads.com/errors/rate-limited", "title": "Rate limit exceeded.", "status": 429, "detail": "Your account is limited to 100 requests per minute. Retry after 24 seconds.", "instance": "/v1/home-services/leads", "requestId": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081"}8. Rate limiting
Section titled “8. Rate limiting”The default limit is 100 requests per minute per key, measured in a fixed one-minute window.
Every POST /v1/home-services/leads response, successful or not, carries the current state.
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Your ceiling for the window. |
X-RateLimit-Remaining |
Requests left in the window. |
X-RateLimit-Reset |
Unix epoch seconds at which the window resets and Remaining returns to Limit. |
Exceeding the limit returns 429 with a Retry-After header in seconds. Honor it; it overrides
your own backoff schedule.
Contact us before a campaign launch if you expect to exceed 100 requests per minute.
9. Duplicates, timeouts, and retries
Section titled “9. Duplicates, timeouts, and retries”Duplicate detection
Section titled “Duplicate detection”Repeated submissions are still processed and can still be sold. A retry after a timeout where the first request succeeded can produce a second purchased lead.
status: "duplicate" and originalLeadId are reserved and not currently returned. Handle them
if they appear.
The Idempotency-Key header is accepted and ignored. Do not rely on it.
Timeouts and retries
Section titled “Timeouts and retries”Processing involves live calls to buyers, so latency is normal.
| Setting | Recommendation |
|---|---|
| Connect timeout | 5 seconds |
| Read timeout | 30 seconds — do not set this lower, or you will abandon leads we are actively selling |
| Retry on | 429, 500, connection failures, read timeouts |
| Do not retry on | 400, 401, 403 |
| Backoff | Exponential with jitter, e.g. 1s, 2s, 4s |
| Max attempts | 3 total |
A read timeout does not mean the lead was not processed. Retry it, knowing that a successful first attempt plus a retry can sell the consumer twice.
10. Sandbox testing
Section titled “10. Sandbox testing”Sandbox credentials (fw_test_sk_) are refused by production, and production credentials
(fw_live_sk_) are refused by sandbox. Validation, rate limits, authentication, error shapes,
and response fields are the same in both environments.
Sandbox outcomes are not deterministic. A given payload is not guaranteed to return accepted,
declined, or duplicate. Handle all three status values, treat an unrecognised status as
not accepted, and treat an unrecognised declineReason as a generic decline.
Do not post real consumer data to sandbox.
If trustedFormCertificateUrl is sent, it is checked for URL shape. A well-formed
https://cert.trustedform.com/... URL passes. Omitting the field is accepted. consentNotVerified
may be returned in a later revision.
Before requesting production credentials, confirm you parse leadId, payout, and
X-Request-Id, treat 4xx other than 429 as terminal, honor Retry-After on 429, and
tolerate the three status values and the six declineReason codes.
11. Compliance and security
Section titled “11. Compliance and security”- Consent. Only post leads where the consumer gave express written consent covering contact
by phone and SMS. Send the exact disclosure text in
consentLanguage. Falsified consent is grounds for immediate termination and chargeback of all affected leads. - TrustedForm. Send a well-formed certificate URL on
cert.trustedform.comwhen you have one. Omitting the field is accepted. When present, the URL is checked for shape.consentNotVerifiedmay be returned in a later revision. - Transport. TLS 1.2 or higher, HTTPS only.
- Data minimization. Post only the fields in this specification. Do not send SSNs, bank account numbers, card numbers, or government identifiers. Unknown fields are ignored rather than rejected, so including them is not a validation error — it is still a compliance failure on your side.
- Logging. Do not log full request bodies containing consumer PII in systems that are not
access-controlled. Log
leadId,X-Request-Id,status, anddeclineReasoninstead. - Credentials. One secret key per environment, covering your whole account rather than one per
campaign: a key says who you are, and
campaignNameon each request says which of your campaigns the lead belongs to. The same secret keys also authenticate/v1/personal-loans/leads. Use a distinctcampaignNamefor each product. - Rotation. Ask us for a replacement key before retiring the one you hold. Both work at the same time, so you can cut over at your own pace and tell us to revoke the old key afterwards. There is no window where posting fails.
12. Versioning policy
Section titled “12. Versioning policy”The major version is in the URL path. Within v1 we make only backward-compatible changes:
- New optional request fields.
- New response fields.
- New enum values on existing fields, including new
tradevalues and newdeclineReasoncodes. - New
typevalues on existing status codes. - Tightened validation on fields already documented as constrained.
Build your integration to tolerate these. Ignore response fields you do not recognize, treat an
unrecognized declineReason as a generic decline rather than a parse failure, treat an
unrecognized status as “not accepted”, and branch on type rather than on the numeric status
code alone.
Breaking changes ship as a new path version with a written deprecation window for the previous one.