Protected payments in one API call.
Mint a branded, hosted checkout link — escrow-held or pay-now — from any marketplace or app. Track it as a transaction, forward funds to the service pro, and stay in sync with signed webhooks. Money-moving endpoints stay locked behind LEGAL_REVIEW so you can build and test the entire flow before a single real dollar moves.
Quickstart — integrate in 5 steps
The fastest path to going live. Each step links to the full reference below.
- Get your workspace & API key. Request access — we provision a workspace for your domain and issue a key (
se_live_…/se_test_…). Each key is scoped to your workspace. - Create a payment link. One POST returns a branded URL you redirect (or hand) your customer to. Choose
escrow(held until approved) ordirect(pay-now). - Pass a
returnUrl. After paying, we show a branded confirmation and send the customer right back to the page they came from on your site. - Listen for webhooks. We POST signed events on milestone state changes and when a
directpayment settles (payment.paid) so your system and ours never drift — even if the buyer closes the tab. - Release & forward. On approval, release the milestone and forward the funds to the service pro (recorded; paid off-platform).
curl -X POST https://www.serviceescrow.com/api/payments/links \
-H "Authorization: Bearer se_live_<YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{
"mode": "direct",
"amountCents": 12500,
"description": "Gutter cleaning — deposit",
"returnUrl": "https://handyman.com/orders/8821",
"cancelUrl": "https://handyman.com/orders/8821?checkout=cancelled"
}'
# → 200
# {
# "ok": true,
# "mode": "direct",
# "url": "https://www.serviceescrow.com/pay/se_pl_9f3c…", ← ALWAYS redirect the customer here (branded)
# "token": "se_pl_9f3c…",
# "reference": "clx…", ← your transaction id
# "stripeUrl": "https://checkout.stripe.com/…", ← raw Stripe; bypasses branding (compat only), null until LEGAL_REVIEW
# "stripeReady": true,
# "expiresAt": null
# }const res = await fetch("https://www.serviceescrow.com/api/payments/links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SERVICEESCROW_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
mode: "direct",
amountCents: 12500,
description: "Gutter cleaning — deposit",
returnUrl: "https://handyman.com/orders/8821",
cancelUrl: "https://handyman.com/orders/8821?checkout=cancelled",
}),
});
const { url } = await res.json();
// redirect the buyer to `url` (the branded /pay/<token> page)Use cases
End-to-end recipes that chain the endpoints below into the flows most integrations need. Set BASE=https://www.serviceescrow.com and your key, then run them top to bottom.
Escrow-protected project with milestones
Home-services marketplaceA homeowner books a multi-milestone job. Funds are held per milestone and only released to the pro once each stage is approved.
# 1) Mirror the project & its milestones into escrow
curl -X POST $BASE/api/escrow/plans \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "externalProjectId": "PROJ-123", "foundingMember": true }'
# 2) Mint a branded hosted escrow link for milestone M-1 (share the returned url)
curl -X POST $BASE/api/payments/links \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "mode": "escrow", "externalMilestoneId": "M-1",
"returnUrl": "https://handyman.com/orders/123" }'
# Homeowner pays → Stripe captures & holds → you receive a signed milestone.funded webhook
# 3) Homeowner approves the stage → release (fee kept, pro marked owed)
curl -X POST $BASE/api/escrow/milestones/M1_ID/release \
-H "Authorization: Bearer se_live_<KEY>"
# 4) Record the off-platform payout to the pro
curl -X POST $BASE/api/escrow/milestones/M1_ID/forward \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "receiverName": "Jane'"'"'s Plumbing", "method": "ach", "reference": "ACH-889912" }'One-off pay-now link
Any site collecting a depositCharge a customer once (deposit, invoice, or booking fee) with a branded checkout page — no milestone lifecycle.
# Create a direct payment link
curl -X POST $BASE/api/payments/links \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "mode": "direct", "amountCents": 12500, "description": "Gutter cleaning deposit",
"returnUrl": "https://site.com/orders/8821",
"cancelUrl": "https://site.com/orders/8821?cancelled=1" }'
# → share the returned "url" — the customer pays on the co-branded /pay/<token> page
# Track it any time
curl "$BASE/api/transactions?status=paid&mode=direct" \
-H "Authorization: Bearer se_live_<KEY>"
# Forward the collected funds to the pro (auto-resolves a saved payee)
curl -X POST $BASE/api/transactions/TX_ID/forward \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "contractorRef": "pro_5521", "reference": "CHK-1042" }'Yearly subscription with per-cycle payouts
Recurring memberships / retainersBill a customer yearly through Stripe. Each paid cycle becomes a charge owed to the pro until you forward it.
# Create a recurring (yearly) link
curl -X POST $BASE/api/payments/links \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "mode": "direct", "interval": "year", "amountCents": 19900,
"description": "Annual maintenance plan" }'
# Customer subscribes on the hosted page → each renewal fires invoice.paid → a recurring charge (owed)
# List cycles owed to the pro
curl "$BASE/api/recurring-charges?payoutStatus=owed" \
-H "Authorization: Bearer se_live_<KEY>"
# Forward one paid cycle
curl -X POST $BASE/api/recurring-charges/CHARGE_ID/forward \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "contractorRef": "pro_5521", "reference": "ACH-2027-01" }'
# Cancel when the customer churns (no further cycles billed)
curl -X DELETE $BASE/api/transactions/TX_ID/subscription \
-H "Authorization: Bearer se_live_<KEY>"Save contractor payout profiles once
Paying the same pros repeatedlyStore each pro's payout destination once, keyed by your contractorRef, so forwards auto-resolve without re-sending receiver details.
# Save the payout profile
curl -X POST $BASE/api/payees \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "contractorRef": "pro_5521", "name": "Jane'"'"'s Plumbing",
"method": "ach", "detail": "ACH …4821" }'
# Forward without receiver fields — the profile fills them in
curl -X POST $BASE/api/transactions/TX_ID/forward \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "contractorRef": "pro_5521", "reference": "ACH-889912" }'
# Archive a profile when a pro leaves
curl -X PATCH $BASE/api/payees/PAYEE_ID \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "status": "archived" }'Dispute and refund a milestone
Support / trust & safetyA homeowner reports incomplete work. Open a dispute (funds stay held), resolve it, and refund if warranted.
# Open a dispute — the milestone moves to 'disputed', funds stay held
curl -X POST $BASE/api/disputes \
-H "Authorization: Bearer se_live_<KEY>" \
-d '{ "milestoneId": "M1_ID", "reason": "Work incomplete", "openedBy": "homeowner" }'
# Resolve it (handled by the ServiceEscrow ops team in the admin console)
# PATCH /api/disputes { "id": "DISPUTE_ID", "status": "resolved", "resolutionNote": "Refunded buyer" }
# Refund the buyer's captured charge
curl -X POST $BASE/api/escrow/milestones/M1_ID/refund \
-H "Authorization: Bearer se_live_<KEY>"Authentication
Every /api endpoint (except the inbound Stripe webhook) requires your workspace bearer key. Send it on every request:
Authorization: Bearer se_live_<YOUR_KEY>
- Workspace-scoped. A key resolves to exactly one workspace (tenant). It can never read or mutate another workspace's data.
- Live vs test.
se_live_…andse_test_…keys are issued per workspace. Keep your keys server-side — never ship them to a browser. - Rotate anytime. Revoked keys immediately return
401. A suspended workspace returns403. - Admin-only endpoints (marked below) are driven by the ServiceEscrow admin console session, not a bearer key — they're for your ops team, not your integration.
Base URL, conventions & errors
https://www.serviceescrow.com
All requests and responses are JSON. Successful responses include "ok": true. Errors return { "ok": false, "error": "message" } with an appropriate status:
200 / 201 success 400 validation error (bad or missing fields) 401 missing / invalid / revoked API key 403 workspace suspended 404 resource not found (or not in your workspace) 409 conflict — wrong state (e.g. milestone already funded, tx already paid) 423 Locked — money-moving endpoint disabled until LEGAL_REVIEW=true
The LEGAL_REVIEW gate. Fund/release/refund and raw Stripe URLs stay behind 423 Locked until legal + payment review is complete for your market. Payment links and transactions can still be created and tested — the hosted page simply mints the Stripe session once the gate is open.
Idempotency. Creating an escrow plan for an externalProjectId that already exists returns the existing plan with "deduped": true instead of creating a duplicate.
Payment links — the fast path
One call mints a branded, hosted ServiceEscrow page at /pay/<token>. The payer sees a reassurance page co-branded with your logo, then continues to Stripe Checkout. Share url — it never carries a stale Stripe link.
/api/payments/linksworkspace key{
"mode": "escrow",
"externalMilestoneId": "M-1",
"returnUrl": "https://handyman.com/orders/123",
"cancelUrl": "https://handyman.com/cart"
}{
"mode": "direct",
"amountCents": 5000,
"description": "Deposit",
"currency": "usd", // optional, defaults to usd
"expiresInDays": 7, // optional
"metadata": { "orderId": "8821" }, // optional (string → string)
"returnUrl": "https://handyman.com/orders/8821",
"cancelUrl": "https://handyman.com/orders/8821?cancelled=1"
}{
"ok": true,
"mode": "direct",
"url": "https://www.serviceescrow.com/pay/se_pl_…", // SHARE THIS
"token": "se_pl_…",
"reference": "clx…", // the transaction id
"stripeUrl": "https://checkout.stripe.com/…", // raw Stripe; bypasses branding (compat only), null until LEGAL_REVIEW
"stripeReady": true,
"expiresAt": "2026-07-15T00:00:00.000Z"
}Branding & sending the payer back
The hosted page shows the ServiceEscrow logo co-branded with your marketplace logo so payers recognise where they are. After a successful payment we show a branded confirmation, then automatically return the payer to your returnUrl:
returnUrl— where the payer lands after paying. Deep-link it to the exact order or booking so the round-trip feels seamless. Omit it to leave them on our confirmation page.cancelUrl— where they go if they back out. No charge is made.- Both must be absolute
https://URLs (validated; no open redirects).
buyer on handyman.com → /api/payments/links (you) → returns branded url → https://serviceescrow.com/pay/<token> (co-branded reassurance page) → Stripe Checkout (card entry, PCI handled by Stripe) → branded "Payment received" confirmation → back to your returnUrl (e.g. handyman.com/orders/8821)
Transactions
A transaction is a payment link — the object ServiceEscrow owns for both direct and escrow payments. The Transactions API gives you search, filtering, and safe CRUD, always scoped to your workspace.
/api/transactionsworkspace keyGET /api/transactions?q=deposit&mode=direct&status=paid&from=2026-01-01&to=2026-12-31&limit=25&offset=0
# q free text over description / token / id
# mode direct | escrow
# status active | paid | void | expired
# from/to ISO dates on createdAt
# limit 1–100 (default 25), offset for pagination
# → {
# "ok": true, "total": 42, "limit": 25, "offset": 0,
# "transactions": [
# { "id": "clx…", "token": "se_pl_…", "url": "https://…/pay/se_pl_…",
# "mode": "direct", "amountCents": 5000, "currency": "usd",
# "description": "Deposit", "status": "paid", "metadata": {…},
# "returnUrl": "…", "expiresAt": null, "paidAt": "…", "createdAt": "…" }
# ]
# }/api/transactionsworkspace key{ "mode": "direct", "amountCents": 5000, "description": "Deposit", "expiresInDays": 7 }
{ "mode": "escrow", "externalMilestoneId": "M-1" }
# → 201 { "ok": true, "transaction": { … }, "stripeUrl": "…", "stripeReady": true }/api/transactions/:idworkspace key/api/transactions/:idworkspace key/api/transactions/:idworkspace keyPATCH /api/transactions/:id
{ "description": "Updated note", "metadata": { "po": "778" }, "expiresInDays": 14 }
{ "status": "void" } // cancel an unpaid link
DELETE /api/transactions/:id // unpaid only
# Safe by design: amount and mode are immutable. A PAID transaction can only have its
# description/metadata edited — it can never be voided or deleted (kept for your records).Recurring subscriptions
Turn a direct payment link into a yearly subscription (e.g. an annual maintenance plan) by adding interval: "year". The customer subscribes on the same branded hosted page, and Stripe bills them automatically each year. Escrow links can't be recurring (there's no per-cycle "approve" step).
/api/payments/linksworkspace key{
"mode": "direct",
"interval": "year", // makes it recurring; amountCents is the per-year price
"amountCents": 19900,
"description": "Annual maintenance plan",
"returnUrl": "https://handyman.com/account/plans"
}
# → { "ok": true, "mode": "direct", "recurring": true, "interval": "year",
# "url": "https://www.serviceescrow.com/pay/se_pl_…", "token": "se_pl_…" }Each paid year is recorded as a recurring charge that's owed to the service pro — forward it exactly like a one-off, per cycle:
/api/recurring-charges?paymentLinkId=&payoutStatus=owedworkspace key/api/recurring-charges/:id/forwardworkspace keyGET /api/recurring-charges?payoutStatus=owed
# → { "ok": true, "charges": [ { "id": "…", "amountCents": 19900, "payoutStatus": "owed",
# "periodStart": "…", "periodEnd": "…", "paidAt": "…" } ] }
POST /api/recurring-charges/:id/forward
{ "receiverName": "Jane's Plumbing", "method": "ach", "reference": "ACH-2027-01" }
# or { "contractorRef": "pro_5521", "reference": "ACH-2027-01" } ← auto-resolve a saved payee/api/transactions/:id/subscriptionworkspace keyDELETE /api/transactions/:id/subscription
# → { "ok": true, "subscriptionStatus": "canceled" } (no further cycles are billed)Subscription status (active / past_due / canceled) is kept in sync from Stripe automatically, and each transaction reports recurring, interval, and subscriptionStatus.
Escrow lifecycle
Capture-only model: the buyer's payment is captured into the platform balance and held. On release we record the platform fee and the amount owed to the pro, which is then forwarded off-platform. There is no Stripe Connect transfer.
pending → held (funded) → released (payoutStatus: owed) → paid_out
↘ refunded ↘ disputed1 · Mirror a project & its milestones
/api/escrow/plansworkspace key{ "externalProjectId": "PROJ-123", "foundingMember": true }
# → { "ok": true, "plan": { "id": "…", "milestones": [ { "id": "…", "externalMilestoneId": "M-1",
# "amountCents": 150000, "status": "pending" } ] }, "deduped": false }2 · Fund a milestone (buyer pays → captured & held)
/api/escrow/milestones/:id/fundworkspace key · gated{ "returnUrl"?: "…", "cancelUrl"?: "…" } // optional
# → { "ok": true,
# "url": "https://www.serviceescrow.com/pay/se_pl_…", ← branded page — SHARE THIS
# "token": "se_pl_…",
# "checkout_url": "https://checkout.stripe.com/…" } ← raw Stripe (bypasses branding; compat only)
# Funding now goes through the same branded /pay page as direct payments.
# (equivalent to /api/payments/links with mode:"escrow")3 · Release on approval (fee kept, pro owed)
/api/escrow/milestones/:id/releaseworkspace key · gated# fee = 2.9% (1.9% founding members), capped $290 per project
# → { "ok": true, "status": "released", "owed_to_pro": 145650, "fee": 4350, "payout_status": "owed" }4 · Refund the buyer
/api/escrow/milestones/:id/refundworkspace key · gated# refunds the buyer's captured charge via Stripe. If the pro was already paid out:
# → { "ok": true, "refunded": 150000, "refund_id": "re_…", "manual_clawback_needed": true }Payouts & payout profiles
There are two parties: the payer (homeowner / buyer) who funds, and the receiver (service doer) who gets paid. Forwarding records the payout (who + method + reference) — you pay the pro off-platform (ACH / check / PayPal) and we keep the ledger straight.
Forward a released milestone
/api/escrow/milestones/:id/forwardworkspace key{ "receiverName": "Jane's Plumbing", "method": "ach",
"receiverEmail"?: "jane@example.com",
"detail"?: "acct …4821", // SAFE reference only — full account numbers are rejected
"reference"?: "ACH-889912" }
# → { "ok": true, "status": "released", "payout_status": "paid_out",
# "paid": 145650, "receiver": "Jane's Plumbing", "method": "ach", "payout_ref": "ACH-889912" }Forward a paid direct transaction
/api/transactions/:id/forwardworkspace key{ "receiverName": "Jane's Plumbing", "method": "check", "reference": "CHK-1042" }
# → { "ok": true, "transaction": { … "status": "paid" } }
# method: ach | check | paypal | stripe | otherPayout profiles (save once, auto-resolve)
Store a contractor's payout destination once — keyed by contractorRef — and a forward auto-resolves it, so you don't re-send receiver details every time. Capture-only & safe: store a method plus a reference / last-4 / note only.
/api/payees?q=&contractorRef=workspace key/api/payeesworkspace key/api/payees/:idworkspace key/api/payees/:idworkspace keyPOST /api/payees
{ "contractorRef": "pro_5521", "name": "Jane's Plumbing",
"method": "ach", "email": "jane@example.com", "detail": "ACH …4821" }
# → 201 { "ok": true, "payee": { "id": "…", "contractorRef": "pro_5521", "name": "…", "method": "ach" } }
# Then forward WITHOUT receiver details — the saved profile fills them in:
POST /api/escrow/milestones/:id/forward { "reference": "ACH-889912" }
POST /api/transactions/:id/forward { "contractorRef": "pro_5521", "reference": "ACH-889912" }Admin-only equivalents (used by your ops team in the console's Payouts / Payees tabs): POST /api/admin/payouts, POST /api/escrow/milestones/:id/mark-paid-out, and GET/POST/PATCH/DELETE /api/admin/payees.
Disputes
/api/disputesworkspace key/api/disputesadmin onlyPOST /api/disputes
{ "milestoneId": "…", "reason": "Work incomplete", "openedBy"?: "homeowner" }
# → { "ok": true, "dispute": { … } } (milestone moves to 'disputed'; funds stay held)
PATCH /api/disputes (admin)
{ "id": "…", "status": "resolved", "resolutionNote": "Refunded buyer" }Embed checkout widget
Start checkout from the browser with a publishable key — no secret se_live_… on the client. Mint the key under Admin → Workspace → Webhook Settings.
/api/embed/checkoutpublishable key<script src="https://www.serviceescrow.com/embed/checkout.js"></script>
<script>
ServiceEscrow.checkout({
publishableKey: "se_pk_live_…",
amountCents: 9900,
description: "Homeowner Plus — 1 year",
returnUrl: "https://yoursite.com/done",
cancelUrl: "https://yoursite.com/cancel",
metadata: { kind: "homeowner_plus", homeowner_id: "42" },
});
</script>
# → redirects to https://www.serviceescrow.com/pay/<token>
# After pay we POST payment.paid to your webhookUrl with the same metadata.curl -X POST https://www.serviceescrow.com/api/embed/checkout \
-H "x-se-publishable-key: se_pk_live_…" \
-H "Content-Type: application/json" \
-d '{ "amountCents": 4900, "description": "Deep Review",
"metadata": { "kind": "deep_review", "project_id": "123" } }'Webhooks
Inbound — point Stripe at us
Configure a Stripe webhook to /api/stripe/webhook. Events are signature-verified and idempotent; a full payout event log keeps both systems in sync. This is our endpoint — marketplaces do not call it.
/api/stripe/webhookStripe signaturecheckout.session.completed → milestone held / transaction paid (+ payment.paid outbound) payment_intent.succeeded → same as above (one-time PI path) invoice.paid → recurring charge recorded (owed) invoice.payment_failed → subscription past_due customer.subscription.* → subscription status synced charge.dispute.created → milestone disputed (+ milestone.disputed outbound)
Outbound — we call your endpoint
We POST signed JSON to your workspace webhook URL (Admin → Settings → Webhook Settings). Deliveries retry with backoff (up to 8 attempts). Verify the x-serviceescrow-signature header — an HMAC-SHA256 of the raw body with your workspace secret — before trusting the event. Respond 2xx to acknowledge.
<your webhookUrl>HMAC signatureEvents: milestone.funded · milestone.released · milestone.refunded · milestone.disputed · payment.paid
POST <your webhook url>
x-serviceescrow-event: milestone.released
x-serviceescrow-signature: sha256=<hmac>
{
"event": "milestone.released",
"workspace": "handyman",
"externalProjectId": "PROJ-123", // your project id
"externalMilestoneId": "M-1", // your milestone id
"seMilestoneId": "cms2w0mhp0002wge8hatbtb51", // ours — the release API takes this one
"status": "released",
"amountCents": 150000,
"payoutStatus": "owed",
"ts": "2026-01-01T00:00:00.000Z"
}POST <your webhook url>
x-serviceescrow-event: payment.paid
x-serviceescrow-signature: sha256=<hmac>
{
"event": "payment.paid",
"workspace": "homemanager",
"reference": "clx…", // same id as GET /api/transactions/:id
"token": "pay_…",
"mode": "direct",
"status": "paid",
"amountCents": 9900,
"currency": "usd",
"description": "Homeowner Plus — 1 year",
"metadata": { "kind": "homeowner_plus", "homeowner_id": "42" },
"interval": null,
"paidAt": "2026-01-01T00:00:00.000Z",
"ts": "2026-01-01T00:00:00.000Z"
}
# Fires once when a direct (or subscription) link first settles.
# Use metadata you passed on POST /api/payments/links to activate products server-side
# (do not depend on the buyer returning to returnUrl). Escrow funding still uses milestone.funded.import express from "express";
import crypto from "crypto";
const app = express();
app.post("/webhooks/serviceescrow",
express.raw({ type: "application/json" }), // IMPORTANT: verify the RAW body
(req, res) => {
const sig = req.header("x-serviceescrow-signature") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.SE_WEBHOOK_SECRET)
.update(req.body) // Buffer of the raw body
.digest("hex");
const ok =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString());
if (event.event === "payment.paid") {
// activate product from event.reference + event.metadata
} else {
// e.g. mark PROJ-123 / M-1 as released in your system
}
res.sendStatus(200); // respond 2xx to acknowledge (else we retry)
});Health & go-live checklist
/api/healthpublicGET /api/health → { "ok": true, "service": "serviceescrow",
"db": true, "stripe": true, "legal_review": false }- Build & test the full flow with your
se_test_…key whileLEGAL_REVIEWis off. - Set your workspace webhook URL & secret and verify signatures for both
milestone.*andpayment.paid. - Save your contractors' payout profiles so forwards auto-resolve.
- Deep-link every
returnUrlback to the exact order/booking for a seamless round-trip. - Complete legal + payment review, flip
LEGAL_REVIEW=true, and go live withse_live_….
Ready to build? Request early access — we'll set up your workspace and keys, and help you wire the first call.