Build with verified support.
A working reference for consented proof requests and campaign evaluation inside Early. Use existing support records, then request one accepted activity when needed.
Authenticated preview integration. These endpoints use an Early Privy session and linked wallet. Public API keys, external-app tenancy and third-party data access are not available yet. The client below is source code inside this application, not a published SDK, and there is no billing or usage plan.
How the pieces fit
Early separates four questions: who is signed in, what happened on a source platform, whether that activity meets a campaign, and whether a reward was paid. Each service answers one of them.
| Component | Role | What it does not do |
|---|---|---|
| Privy | Signs users in to Early and links the wallet used for sessions and claims. API requests carry a Privy access token. | It does not see or prove Spotify or Google activity. |
| Reclaim Verifier | Runs on the user’s phone. The user signs in to the source inside Reclaim, and a zkTLS proof of selected response fields is sent to Early’s callback. | Credentials are not sent to Early. A Verifier proof key is not an Early account or a source-account identity. |
| Early server | Checks the proof signature, pinned provider version and recipe hash, session binding, target and timestamps; stores normalized evidence; evaluates rules; signs expiring reward authorizations. | It does not broadcast transactions for the user. |
| Supabase (Postgres) | Private storage for sessions, source bindings, normalized evidence, immutable rules, evaluations, authorization reservations and encrypted retained proofs. | It is not public and is never queried directly from the browser. |
| Monad testnet escrow | Holds creator-funded test MON and enforces payout, capacity, expiry, signer, wallet and duplicate-source constraints. | It does not verify Reclaim proofs onchain; it trusts Early’s eligibility signer. |
| Envio | Indexes escrow creation, funding, claim and closure events into a queryable activity feed. | It is not the source of truth. RPC receipts and contract state are authoritative. |
Active deployment: Monad testnet, chain 10143, escrow 0x32192Beb858561c67CCe7C1b2dbdD60E8484Df41, deployment block 70207421. Older Base, Zama and Stellar code in the repository is historical and not part of this flow.
Three providers, three different claims
Each route has its own Reclaim provider, target format, extraction and evidence. A rule names the route explicitly; one route never satisfies another.
| Route | Target format | What an accepted proof establishes | Coverage and limits |
|---|---|---|---|
spotify_saved_track | spotify:track:<22 chars> or spotify:artist:<22 chars> | The date a selected song was saved to the user’s library. An artist target matches a saved song by that artist. | The selected saved item only. Not listening history, follows, albums or pre-saves. |
youtube_subscription | Channel ID UC… (24 chars) | A dated subscription to that channel, as recorded in Google My Activity. | The loaded English My Activity page. Does not prove watching or liking any video. |
youtube_like | Video ID (11 chars), e.g. dQw4w9WgXcQ | A dated like of that exact video, bound to its channel, as recorded in Google My Activity. | The first loaded English likes page, which lists recent likes. An older like may not appear there. Does not prove watching, discovery or a first-ever like. |
Timestamps are stored in UTC microseconds with a precision window. A record whose window overlaps a cutoff is indeterminate, not eligible. A missing record on a loaded page does not prove the activity never happened. Provider IDs, versions and recipe hashes are pinned in src/lib/early/providers.ts; the server rejects proofs from any other version, and the deployment’s environment must match the pin or sessions fail with PROVIDER_CONFIGURATION_MISMATCH. resolve-target turns a public share link (open.spotify.com, youtube.com/watch, youtu.be, Shorts) into the canonical target.
Verify one accepted alternative
The typed browser client lives at src/lib/early/client.ts. Import it within this application; it is not a published SDK. The variables below come from the signed-in account and the user’s campaign selection.
import { createEarlyClient } from "@/lib/early/client";
// Within Early's browser app, with its Privy account context:
const early = createEarlyClient({ wallet: walletAddress, getAccessToken });
const { rules } = await early.listCampaigns();
// Let the user select a published campaign and give consent.
const { evaluation } = await early.inspect(selectedRule.id, consent);
if (evaluation.status !== "eligible") {
// Only after the user chooses an alternative and clicks Verify:
const session = await early.startVerification(
selectedRule.id, selectedRule.definition.alternatives[selectedIndex], consent
);
// Show session.requestUrl as a link or QR. Never auto-open/auto-collect.
const status = await early.verificationStatus(selectedRule.id, session.sessionId);
if (status.status === "verified") {
const result = await early.evaluate(selectedRule.id);
// result.status is eligibility, not a confirmed payout.
}
}Each campaign supplies immutable targets and explicit UTC cutoffs, such as 2026-10-01T00:00:00.000Z. Spotify saved-track evidence can match a track or its verified artist relationship. YouTube subscription evidence matches a channel; YouTube like evidence matches one exact video. Alternatives use OR. Repeat-support rules still require their stated distinct event/day counts.
Access, ownership and consent
- Authentication: every operation requires
Authorization: Bearer <Privy access token>and awalletfield. The server checks that the wallet is linked to that Privy account; otherwise it returns401 UNAUTHENTICATED. - Origin: browser requests must come from an origin in
ALLOWED_APP_ORIGINS, or the server returns403 ORIGIN_FORBIDDEN. Third-party sites cannot call these endpoints today. - Session ownership: a verification session belongs to the account and wallet that created it. Knowing a session ID does not grant access (
403 SESSION_FORBIDDEN). - Source binding: the first accepted proof binds a source account (a Spotify or Google account) to the Early account. A proof from the same source on another Early account is rejected with
SOURCE_ALREADY_BOUND. One Google binding covers both YouTube routes. - Consent:
inspectandsessionsrequireconsent: true. Ask for it in the interface before reading saved records or starting a proof, and never open the Verifier automatically. - Campaign management: creating, publishing and funding campaigns requires an Early team account and an approved organizer. Public sign-in does not grant those permissions.
Request and response contract
All operations below use POST /api/early/{operation}, JSON, Authorization: Bearer <Privy access token>, and a wallet field owned by that account. Browser requests must originate from the configured Early app origin. Responses are private and uncached; request bodies are limited to 1 MiB.
| Operation | Additional request fields | Response |
|---|---|---|
resolve-target | route, link | targetId, label, url for a public share link. |
list-rules | None | rules[] with campaign IDs, definitions, targets and cutoffs, plus access and capabilities. Only published campaigns from approved organizers are returned. |
inspect | ruleId, consent: true | evaluation, routes, capabilities. Checks owned stored evidence; does not browse source accounts. |
sessions | selection: {route, targetId, cutoff}, consent: true, optional ruleId | sessionId, requestUrl, expiresAt, rewardReady. With a ruleId, the selection must match one of its alternatives. Sessions expire after 15 minutes. |
session-status | sessionId, optional ruleId | status, errorCode, evidenceId, expiresAt, rewardReady. A session is private to its bound account/wallet. A rejected proof appears here as technical_failure with its errorCode (for example SOURCE_ALREADY_BOUND or WRONG_TARGET). |
evidence | None | records[]: the account’s accepted records (platform, activity, target, timestamp, precision, coverage). |
evaluate | ruleId | status, evidenceIds, reason. Evaluates owned records and persists the result. |
| Operation | Request fields | Response |
|---|---|---|
rules (team) | rule | Saves an immutable rule draft. An identical rule returns the existing record. Corrections require a new rule. |
publish-rule (team) | ruleId, organizationId | Publishes under an approved organizer. A published rule cannot move to another organizer. |
campaign-benefits | ruleId | Linked escrow benefits and their status: available, unavailable, terms_mismatch or confirmation_pending. |
transaction | action: create (ruleId, amountWei), fund (benefitId, amountWei), claim or refund (benefitId) | Unsigned transaction data for the user’s wallet to sign. Early never broadcasts it. |
authorize | benefitId | An EIP-712 claim authorization (wallet, amount, five-minute expiry, source nullifier) after re-evaluating eligibility. |
reconcile | txHash | submitted, confirming or a confirmed result read from the RPC receipt and escrow events. |
indexed | benefitId, optional txHash | Envio’s view of the benefit and claims, or not_configured/unavailable. Index delay is not a payment failure. |
POST /api/early/sessions
Authorization: Bearer <Privy access token>
Content-Type: application/json
{
"wallet": "0xYourLinkedWallet",
"ruleId": "<published campaign ID>",
"consent": true,
"selection": { "route": "youtube_like", "targetId": "dQw4w9WgXcQ", "cutoff": "2026-10-20T00:00:00.000Z" }
}
200 OK
{
"sessionId": "<Reclaim session ID>",
"requestUrl": "<Reclaim Verifier link — show as a button or QR>",
"expiresAt": "2026-10-13T12:15:00.000Z",
"rewardReady": false
}POST /api/early/session-status
{ "wallet": "0xYourLinkedWallet", "ruleId": "<campaign ID>", "sessionId": "<Reclaim session ID>" }
200 OK
{ "sessionId": "…", "status": "verified", "errorCode": null,
"evidenceId": "…", "expiresAt": "…", "rewardReady": false }
// A rejected proof is reported on the session, not as an HTTP error:
200 OK
{ "sessionId": "…", "status": "technical_failure", "errorCode": "SOURCE_ALREADY_BOUND",
"evidenceId": null, "expiresAt": "…", "rewardReady": false }The Verifier submits signed proofs to Early’s callback. Keep callback capabilities, raw proofs and source identifiers out of logs, analytics and public URLs. Never embed a service-role key, signer key or Privy app secret in client code.
Keep proof, eligibility and payment separate
- verified: the server accepted and stored a proof. It may still fail a campaign’s cutoff or target rule.
- eligible: stored verified records meet the rule. Funds, expiry and duplicate protection still apply.
- awaiting_proof: collect an accepted activity only with consent.
- not_qualifying: current records do not meet the rule. Incomplete coverage does not prove the user has no other qualifying activity.
- indeterminate: source timestamp precision overlaps the cutoff.
- technical_failure / expired: recover the request or start a fresh one after expiry. Neither establishes ineligibility.
Supported Spotify and YouTube records can qualify for testnet rewards. Authorization checks campaign terms, account-bound evidence, funding, expiry and prior claims. Saving a proof alone does not authorize payment, which is why session responses always return rewardReady: false. Artist-library, album, pre-save, comment, listening and watch routes remain unfinished. The example does not broadcast transactions. A confirmed Monad receipt and canonical indexed event are separate payment evidence.
Errors and retry behavior
Errors use {"error":{"code":"…","message":"…"}}. Never retry a failed proof by weakening verification.
| Status | Codes | What to do |
|---|---|---|
| 400 | CONSENT_REQUIRED, INVALID_TARGET, INVALID_CUTOFF, SELECTION_MISMATCH, WRONG_TARGET, INVALID_PROOF_RECIPE, INVALID_PROOF_SIGNATURE | Fix the input or selection. A rejected proof needs a fresh session for the correct target; do not resubmit it. |
| 401 / 403 | UNAUTHENTICATED, ORIGIN_FORBIDDEN, SESSION_FORBIDDEN, TEAM_REQUIRED, CREATOR_FORBIDDEN | Sign in again with the account that owns the wallet, or use an account with the required role. |
| 409 | SESSION_EXPIRED, SOURCE_ALREADY_BOUND, SOURCE_RELINK_FORBIDDEN, NOT_ELIGIBLE, BENEFIT_UNAVAILABLE, ALREADY_CLAIMED, AUTHORIZATION_EXPIRED, REORG_PENDING | Start a new session after expiry; renew an expired authorization by calling authorize again; wait and reconcile again after a reorg. Source and claim conflicts are final for that account. |
| 503 | PROVIDER_CONFIGURATION_MISMATCH, PROOF_VERIFICATION_UNAVAILABLE, RPC_UNAVAILABLE, SIGNER_MISMATCH, TECHNICAL_FAILURE | Service or configuration problem. Retry later; it says nothing about the user’s eligibility. |
Sessions, replay and duplicates
- Each session carries a signed context: session ID, wallet, route, provider version, recipe hash, selection and a random nonce. A proof whose context does not match the server session is rejected (
SESSION_BINDING_MISMATCH). - The proof must be produced inside the session window (
PROOF_TIME_MISMATCH), and a session accepts only one proof (SESSION_CONFLICT). The callback URL carries a random per-session capability; only its hash is stored. - Recovering a stored record does not need a new proof: reopen
/rewards?session=<id>or/dashboard#supportwhile signed in to the same account. - Reproving the same source event creates no extra support event; events are keyed by a server-side nullifier. Unliking and liking again creates a new, later event.
- Each claim uses a per-benefit source nullifier. The escrow rejects a second claim from the same source or the same wallet.
- Limits: source binding cannot show that two independently controlled accounts belong to different people, and it cannot stop a user from sharing their own session link.
Campaign lifecycle
- A team member saves a rule (example below). Payout is in wei;
claimExpiryis Unix seconds. The cutoff (when activity must have happened) and the claim expiry (when claims close) are different times. - Publish it under an approved organizer. Publication does not fund anything.
- The creator wallet calls
transactionwithcreate, thenfund, and signs both. Funding cannot exceed payout × capacity. - A fan with eligible records calls
transactionwithclaim, signs it, and the app callsreconcilewith the hash until it is confirmed. Envio updates follow. - After expiry, the creator can refund the unclaimed balance. The admin cannot withdraw campaign funds.
{
"version": 1,
"title": "Early demo — exact video like",
"alternatives": [
{ "route": "youtube_like", "targetId": "dQw4w9WgXcQ", "cutoff": "2026-10-20T00:00:00.000Z" },
{ "route": "spotify_saved_track", "targetId": "spotify:track:<22-character ID>", "cutoff": "2026-10-20T00:00:00.000Z" }
],
"minimumDistinctEvents": 1,
"minimumDistinctDays": 1,
"payoutWei": "10000000000000000",
"capacity": 10,
"claimExpiry": 1761523200
}Gas: verification needs no gas. Claims use Privy gas sponsorship only when it is funded; otherwise the user must explicitly choose to pay with their own test MON from the Monad faucet. A failed sponsored transaction is never silently retried as user-paid.
Privacy and retained data
- Stored privately: normalized evidence (route, target, channel, timestamp, coverage), a keyed source-account nullifier, sessions and evaluations.
- Raw proofs are encrypted at rest and deleted after
RAW_PROOF_RETENTION_HOURS(1–168, default 24). Cleanup runs once a day, so deletion can lag by up to a day. - Public onchain: the claiming wallet, benefit ID, amount, transaction and a source nullifier that cannot be reversed into the source account. Source activity and proofs are never written onchain.
- The source platform still holds its own record of the activity. See the privacy policy for user-facing terms.
Run it locally
- Node 22 and pnpm 9:
pnpm install. - Copy
.env.exampleto.env.local. You need your own Reclaim app, Privy app, Supabase project, raw-proof encryption key, source-nullifier secret and an eligibility signer. Provider IDs and versions must equal the pins inproviders.ts. - Apply the SQL in
supabase/in order:early_monad_v3,early_partner_access_v4,early_campaign_publication_v5,early_sponsored_receipts_v6,early_youtube_like_v6. - Run
pnpm dev. Reclaim must reach your callback, so phone proofs need a publicly reachableNEXT_PUBLIC_APP_URL. - Checks:
pnpm test:app,pnpm typecheck,pnpm lint,pnpm test:monad(Foundry),pnpm check:indexerandpnpm test:e2e.
Troubleshooting: PROVIDER_CONFIGURATION_MISMATCH means environment provider settings differ from the pins; a Verifier success with no record usually means the callback could not reach your server; a YouTube like that is “not found” is often older than the first loaded likes page.
Try it with your account
Published campaigns and your own records are loaded only when requested. No sample records are substituted when a campaign or service is unavailable.
Loading your account…