Sandbox
Test-mode keys (lp_test_*) that exercise the full Partner API contract with deterministic fakes — no real platform calls, no credit burn, identical response envelopes.
A sandbox key (lp_test_…) flips every credit-burning, platform-calling code path into a deterministic fake. Same routes, same response envelopes, same webhook signatures — just no real Meta / TikTok traffic, no real OAuth, no real generation cost. Wire your integration once; flip the prefix to ship.
What sandbox is
A sandbox key opts the entire request envelope into test semantics:
- No real platform calls. Meta Graph, TikTok Business, Apple Search Ads — all short-circuited.
- No credit burn. The credit gate returns
{ ok: true, sandbox: true }immediately and the ledger isn't touched. - Deterministic fixtures. Same input → same output, every time. Re-running a test from CI yields byte-for-byte identical media URLs and metric values.
- Real response envelopes.
content_containers,scheduled_posts,social_accounts, jobs, webhook payloads — all return the same shape as live. Your client code is identical. - Real-but-fake OAuth completion. Sandbox redirects don't bounce through Instagram or TikTok; they complete server-side and produce a
social_accountsrow.
The contract: anything you build against a sandbox key works against a live key with no client changes. The only thing you swap is the key prefix. Sandbox is strictly key-based — there is no header override, no env query param, no per-request opt-in. The key prefix is the switch.
How to mint a test key
Mint a test key from your Layers dashboard's API Keys page — there's a "Test mode" toggle on the create-key form. If your account isn't yet provisioned for self-serve test keys, contact your Layers contact and they'll mint one alongside your live key.
The full key is shown exactly once at creation. Paste it into your secrets manager.
The shape:
lp_test_<keyid>_<secret>A live key starts with lp_live_; a sandbox key starts with lp_test_. Everything after the prefix is the same opaque secret format. The prefix is the only signal — there is no header, no query param, no per-request opt-in. Sandbox keys come back at pilot-tier rate limits (10× the standard ceiling) so a CI fleet hammering them won't trip 429s under normal use.
Use it the same way you use a live key — bearer auth, every request:
export LAYERS_API_KEY="lp_test_…"
curl https://api.layers.com/v1/whoami \
-H "Authorization: Bearer $LAYERS_API_KEY"const res = await fetch("https://api.layers.com/v1/whoami", {
headers: { "Authorization": `Bearer ${process.env.LAYERS_API_KEY}` },
});
const me = await res.json();
console.log(me.organizationId, me.rateLimitTier);import os, requests
res = requests.get(
"https://api.layers.com/v1/whoami",
headers={"Authorization": f"Bearer {os.environ['LAYERS_API_KEY']}"},
)
me = res.json()
print(me["organizationId"], me["rateLimitTier"])/v1/whoami returns the same body shape it returns for a live key. The signal that the key is in test mode is the prefix — Layers does not surface a top-level env field on whoami today.
Mint a key per environment in your stack — typically one for CI, one for local dev. Treat the secret like a production credential even though it can't move money: a leaked sandbox key still lets a third party rack up sandbox usage against your org's quota counters.
Per-domain behavior
Sandbox is not one switch. It is five separate short-circuits — one per domain. Each is described below with the request shape, the response shape, and the points where sandbox diverges from live.
Social OAuth
Live POST /v1/projects/:projectId/social/oauth-url returns a Meta or TikTok URL that the user visits to grant access. The platform redirects back to Layers with a real authorization code; Layers exchanges the code for a token; a social_accounts row is written.
Sandbox replaces the platform redirect with an internal completion route. The oauth-url response carries:
{
"url": "https://api.layers.com/v1/social/sandbox-complete?state=<token>&platform=instagram",
"state": "<token>"
}Hit the URL with a GET — your test runner can fetch it; no browser needed. The route validates state (a real PKCE row, minted by the sandbox key), runs the same runPostConnectSideEffects the live callback runs, and returns 200:
{
"state": "<token>",
"status": "completed",
"socialAccountId": "sa_9c1e42a0...",
"platform": "instagram",
"handle": "sandbox_instagram",
"connectedAt": "2026-05-08T12:00:00.000Z",
"sandbox": true
}The underlying social_accounts.platform_user_id is sb_<platform>_<keyId-prefix>_<stateHash> — per-state-row, where <keyId-prefix> is the first 8 chars of the api-key UUID with hyphens stripped and <stateHash> is an 8-char SHA-256 of the state token. Each oauth-url call mints a fresh state token, so three oauth-url calls produce three distinct social_accounts rows. Replaying the same completion URL is idempotent — the existing row's terminal payload is returned without re-minting. The vault token is the literal string "sandbox_token"; no publishing path ever uses it because publishing also short-circuits.
Listing the account back via GET /v1/projects/:projectId/social/accounts returns it identically to a real account — no carve-out, no kind: "sandbox" field on the row. The signals are the sb_<platform>_… prefix on platform_user_id, social_accounts.is_sandbox = true on the row, and the meta.sandbox: true flag on the webhook envelope.
The social_account.connected webhook fires with meta.sandbox: true on the outer envelope — useful for testing your handler end-to-end against signed real-shape deliveries. See How to detect a sandbox webhook below.
Publishing
Live POST /v1/content/:containerId/publish and …/schedule insert a scheduled_posts row, dispatch a Temporal workflow, and eventually call Meta or TikTok.
Sandbox skips Temporal. The handler:
- Inserts a
platform_postsrow directly withstatus: "published",platform_post_iddeterministic assb_post_<containerId>_<targetIdx>,permalink: null, andposted_at: now(). - Marks the
scheduled_postsrowpublishedimmediately. No queue, no wait. - Returns the same
202 { jobId, scheduledPostId, … }envelope. The job is a no-op — poll it and it terminatessucceededon the next tick. - Fires the
post.publishedwebhook withmeta.sandbox: trueon the outer envelope (the per-eventdatapayload is unchanged). The webhook is emitted synchronously after both row writes, so a handler receiving it can immediately queryplatform_posts.is_sandbox = truefor thatplatform_post_id.
The deterministic id pattern is the join key for synthetic metrics — see the next section.
Metrics
The post-level metrics tables carry no rows for sandbox platform_post_ids — sandbox publishing never round-tripped through a real platform.
The post-level metrics endpoint (GET /v1/projects/:projectId/metrics) synthesizes on read. For any sandbox-marked post in the result set, the handler computes a deterministic series seeded from the id, then slices it against the caller's from / to query params:
- Plausible ranges. Views ramp ~100/day with ±20% jitter; engagement rate sits in the 2–5% band.
- Same shape as real. Field set matches the live
LoadOrganicSeriesResultschema —views,reach,likes,comments,shares,saves,watch_time_ms,posts_published,engagement_rate. No new fields, no missing fields. - Deterministic. Two calls with the same id and date range return byte-identical results.
Mixing sandbox and live ids in one call is supported — real ids continue to read from real tables. Sandbox ids route through the synthesizer at api/lib/partner/sandbox-metrics.ts (FNV-1a-seeded mulberry32 PRNG).
Ad-level metrics (Meta / TikTok / Apple Search Ads daily metrics) are not synthesized this cycle — see What sandbox does NOT mock.
Content generation
Live POST /v1/projects/:projectId/content/{format} dispatches a Temporal workflow that runs LLM and E2B steps and writes asset URLs into content_containers.
Sandbox skips Temporal. The handler inserts content_containers rows with status: "completed", pointing at fixture assets at media.layers.com/sandbox/...:
https://media.layers.com/sandbox/<format>/sample-<n>/<asset>Per-format fixture set:
| Format | Layout | Example asset URL |
|---|---|---|
slideshow | 5 ordered jpgs (1080×1920) | …/slideshow/sample-1/slide-1.jpg … slide-5.jpg |
video-remix | 1 mp4 (~10s, 1080×1920) + thumbnail jpg | …/video-remix/sample-1/video.mp4, …/video-remix/sample-1/thumbnail.jpg |
ugc-creative | 1 mp4 + thumbnail jpg | …/ugc-creative/sample-1/video.mp4, …/ugc-creative/sample-1/thumbnail.jpg |
image | 1 jpg | …/image/sample-1.jpg |
Each format ships three fixture sets (sample-1 / sample-2 / sample-3) so a variantCount: 3 request fans out to three distinct previews. Fixture index = FNV-1a(containerId) mod fixtureCount, so the same container id always lands on the same fixture; different container ids spread evenly across the catalog.
The hook you pass is persisted on metadata.partner.hook but does not steer the fixture. That is by design: sandbox content gen tests your client code's API handshake, not the LLM. If you need the hook to round-trip through a real generator, use a live key against a throwaway project.
The preview object is populated as if real content was generated — same shape, fixture URLs in primaryUrl, thumbnailUrl, imageUrls, videoUrl. Your renderer doesn't branch on sandbox vs live.
The fixture catalog lives at api/lib/partner/sandbox-fixtures.ts; the partner-API and the Temporal mirror copy share the same shape (path-alias mirroring, same convention as deriveApprovalStatus). Bucket seeding to media.layers.com/sandbox/* is in flight (tracked by ops) — until ops uploads the assets, fixture URLs may return 404 on fetch but the API contract still round-trips correctly.
Sandbox containers carry is_sandbox = true on the database row (the canonical detection mechanism); the URL pattern is the user-facing signal but the row column is what every internal join keys on.
Uploaded content
Both upload transports short-circuit under a test key — zero real fetches, zero storage writes, byte validation skipped entirely:
- URL-fetch (
POST /v1/projects/:projectId/content/upload): the URLs are never fetched. You get a201completed item backed by fixture media, with your caption and hook persisted verbatim. - Direct upload (
POST /v1/projects/:projectId/content/uploads→ PUT → finalize): the session response has the normal shape, but theuploadUrls are inert — skip or ignore the PUT in sandbox. A PUT against an inert URL fails harmlessly and nothing reads it.finalize-uploadshort-circuits to fixture media and returns the completed item with your caption.
Because size caps, Content-Type checks, and media probing only run against live keys, sandbox can't validate that your actual files are publishable — it validates your client's handshake (session shape, PUT sequencing, finalize idempotency, error handling). Run one live upload before shipping.
Sandbox uploads insert real (flagged) rows but never count against the per-project upload quota — a sandbox CI loop can run forever without starving live uploads.
Credits
The credit gate returns { ok: true, sandbox: true } immediately for sandbox calls. No ledger read, no balance check, no 402 BILLING_EXHAUSTED.
The charge path is a no-op — but it still emits an audit row with sandbox: true and the would-have-been-cost. So:
- The credit ledger is not written. Run a sandbox CI loop forever; balance won't move.
- The audit log shows
credit.skippedrows with the cost that would have been billed. Useful for forecasting real spend before you flip to a live key.
The audit row schema is the same as a real charge plus the sandbox flag, so existing analytics queries continue to work — filter on sandbox = false for real-spend reporting.
How to detect a sandbox webhook
Sandbox-origin webhook deliveries carry an envelope-level meta.sandbox: true flag. The flag lives on the outer envelope, not inside data — per-event payload schemas stay pristine, so your typed data parsers continue to work without changes.
{
"id": "evt_01KPM7QZEC6NJF4XJTCZRR6S3N",
"type": "post.published",
"apiVersion": "v1",
"createdAt": "2026-05-08T12:00:00.000Z",
"meta": {
"sandbox": true
},
"data": {
"scheduledPostId": "sp_b9b66cde...",
"containerId": "cnt_7d18b9a1...",
"externalId": "sb_post_<containerId>_0",
"externalUrl": null
}
}meta is optional and omitted on live deliveries — treat any of meta absent, meta.sandbox absent, or meta.sandbox === false as live. A typical handler:
function handleWebhook(event: WebhookEvent) {
if (event.meta?.sandbox) {
// route to test queue, no on-call paging, etc.
return enqueueTest(event);
}
return enqueueProd(event);
}X-Layers-Signature is computed over the entire raw body — meta included — so your verifier doesn't branch on sandbox; it just verifies. See Webhooks → Sandbox payloads for the full delivery shape.
What sandbox does NOT mock
Idempotency, signing, and rate-limiting all behave identically to live. The full list of things that don't get a sandbox carve-out:
- Layers billing. Sandbox usage doesn't bill — but org-level quota counters still increment. If your org has a per-month API-call ceiling, sandbox traffic counts. Intentional: a runaway test loop should still trip your alerting.
- Webhook delivery + signing. Sandbox payloads are signed identically to live (
X-Layers-SignatureHMAC over the raw body —metasegment included). Your verifier should not branch on sandbox vs live; it just verifies. The sandbox flag rides on the envelope'smeta, not on the signature scheme. - Authentication and scopes. A sandbox key with the wrong scope still returns
403 FORBIDDEN_SCOPE. A revoked sandbox key still returns401 UNAUTHENTICATED. A killed sandbox key still returns503 KILL_SWITCH. Test your error paths. - Idempotency.
Idempotency-Keyworks the same way against sandbox as live. Replays return the cached response. - Rate limits. Sandbox keys get the
pilot-tier bucket but the same headers, the same429envelope, and the sameRetry-Aftersemantics. Use a sandbox key to exercise your back-off code. - Ad-platform metrics (Meta Ads, TikTok Ads, Apple Search Ads daily metrics). Sandbox covers post-level metrics for sandbox-published content. Ad-level metrics for sandbox-flagged campaigns aren't synthesized this cycle — partners testing ad integrations should connect a real BYO sandbox ad account on the platform side (Meta has its own ads-API sandbox surface) and pair it with their
lp_test_*Layers key. Per the BYO contract, partner ad spend is platform-billed anyway, so this is the architecturally honest framing.
Limitations
Sandbox short-circuits the platform call. It does not run the platform's validation logic.
- No platform-policy validation. Sandbox publish always succeeds. A live publish that Meta or TikTok would reject (banned hashtag, invalid aspect ratio, rate-limited account, broken token) reports
succeededin sandbox. Smoke against a live key before shipping. - No ad-platform write validation. Sandbox ad writes always succeed. Real Meta / TikTok / Apple campaign-create endpoints reject malformed targeting, unsupported pixel events, missing creative variants — sandbox does not. Treat ad-side validation as live-only.
- No live token semantics. Sandbox
social_accountsrows carry a sentinel vault token. They never need refresh, never expire, never tripsocial_account.needs_reauth. Test the reauth flow against a live key. - No real metrics convergence. Sandbox metrics are deterministic — never noisy, never delayed. You can't use sandbox to test code that handles "metrics not yet available" or "metrics still landing" states; everything is "available now."
If you need to test policy-rejection paths, use a live key against a throwaway project. The Layers integration-credit grant covers this.
Switching from sandbox to production
Swap the prefix. That's it.
- LAYERS_API_KEY="lp_test_AB23CD45EF67GH89_…"
+ LAYERS_API_KEY="lp_live_XY45ZA67BC89DE01_…"No code changes. Same endpoints, same response shapes, same webhook envelopes (live deliveries simply omit the meta.sandbox flag). The only behavioral differences are the ones listed above: real platform calls, real credit burn, real OAuth, real metrics, real validation.
The two-environment pattern most partners settle on:
- CI smoke tests run against a sandbox key. Fast, free, deterministic.
- Pre-prod canary runs against a live key on a throwaway project. Catches platform-validation gotchas before they hit a customer.
- Production traffic uses a separate live key per integration.