VCurl — implementation brief for AI agents ============================================================ A quickstart for building against the VCurl API: the common calls, with copy-paste examples. For the FULL reference — every endpoint, field and response shape, plus update/delete, filters, pagination and more — read the OpenAPI spec: https://staging.vcurl.co/docs/api.json (browsable UI: https://staging.vcurl.co/docs/api) == GET A KEY (read first) == Bearer tokens. You (the agent) must NOT invent, guess or reuse a credential — ask your human to create a scoped API token in their dashboard (for a game, a one-click "submit key") and paste it back. Send it on every request: Authorization: Bearer Scope each token to what you need (request the narrowest set): links:* pastes:* countdowns:* files:* trackers:* bins:* emailbins:* (each :read :write :analytics/…) forms:read/write/submissions/submit · polls:read/write/votes/submit · games:read/write/submit availability:read/write/responses/submit gitconcierge:read/write/submit (support desks & tickets) · sendbrand:read/write (SendBrand brands & email signatures) site-association:read/write · metrics:read mirrors:read/write · scrobbles:read (ScrobbleWith) · secretgifts:read/write (SecretGifts) [per-user — GLOBAL base] Check what a token can do: GET https://staging.vcurl.co/api/v1/token (any token, no ability needed) BASE URLS: global https://staging.vcurl.co/api/v1 · team-scoped https://.staging.vcurl.co/api/v1 == BEFORE YOU BUILD: THERE MAY BE NOTHING TO BUILD == This document is for WRITING CODE against the API. If your human's goal is simply to *use* VCurl through you — "show me my open tickets", "shorten this link", "update the brand colours" — do NOT implement a client. Connect to an MCP server instead and call its tools. Same tokens, same permissions, no integration code, and the tool descriptions tell you the arguments so there is nothing to look up here. Transport: streamable HTTP, POST. Auth: Authorization: Bearer , as above. https://staging.vcurl.co/mcp/vcurl links, pastes, countdowns, files, trackers, bins, forms, polls, availability, games & scoreboards (team-scoped) https://staging.vcurl.co/mcp/gitconcierge support desks, tickets, replies, changelogs (team-scoped) https://staging.vcurl.co/mcp/sendbrand brand guides & email signatures (team-scoped) https://staging.vcurl.co/mcp/scrobble mirrors & scrobbles (per-user) https://staging.vcurl.co/mcp/secretgifts gift exchanges (per-user) https://staging.vcurl.co/mcp/user the signed-in account, its teams and activity Team-scoped servers take an optional `team` UUID argument; omit it when the human has one team. Build against the REST API when you need something the tools do not cover, when the work runs unattended, or when you are shipping a product of your own. Otherwise: connect, don't code. is your team subdomain (from the dashboard); nearly every call below is team-scoped. == WHAT YOU CAN CREATE (HOST TYPES) == Each of these is a "host" — an object you create via the API and that the public then reaches through a slug (see the next section). Base paths are team-scoped (https://.staging.vcurl.co/api/v1): Short link /team/links a branded short URL that redirects to a destination (UTM + optional rule-based routing by geo/OS/time/…) Paste /team/pastes a titled markdown document, like a pastebin Countdown /team/countdowns a live page counting to (or up from) a moment: one date, a recurrence (daily-yearly, always next instance) or a list of dates (soonest upcoming); optional style + background File /team/files one hosted upload — image/video/PDF embeds on its page, anything else downloads Tracking pixel /team/trackers a pixel or hit-counter you embed in an email or page; every load counts Request bin /team/bins a throwaway URL that captures every HTTP request sent to it (inspect a webhook) Email bin /team/emailbins a throwaway inbox address (@) that captures every email sent to it Feedback form /team/forms a public form; responses collected privately for the team Poll /team/polls a public poll; votes collected, with an optional live tally Availability /team/availabilities a "when works?" poll; people pick time slots, best common time surfaced GitConcierge /team/support-desks a support desk backed by your git issues; open tickets via gitconcierge/{desk}/tickets Game /team/games umbrella for SCOREBOARDS (score tables) + ACHIEVEMENTS + daily STREAKS A game and each of its scoreboards are also slug hosts, so either can be published at a public URL. Separately, SITE ASSOCIATION (/team/site-associations) serves apple-app-site-association / assetlinks.json on a verified domain for mobile deep links — it's domain-level, not a slug. == HOW PUBLIC SLUGS WORK == A SLUG is the public URL that fronts a host — the ONLY public web surface. The public always reaches your object through its slug. - Creating a host mints its FIRST slug in the same call; add/remove more under .../{id}/slugs. One host can have MANY slugs (many public URLs pointing at the same object). - A slug lives on a domain: pass `domain` = a verified domain's UUID you own, or `null` for a shared public domain. The public URL is then https:/// - `path`: letters/numbers/-/_ ; omit it and a memorable name is generated (e.g. impulsive-pintail). - Per-slug guards: `password` (gate access), `expires_at` (stop resolving after a time), `max_hits` (retire after N hits). Hits count PER SLUG, so every URL is tracked separately. - Every slug has a scannable SVG QR at .../slugs/{slug}/qr; click-tracked hosts expose .../analytics. The direct https://.staging.vcurl.co/{type}/{uuid} page is a SIGNED-IN team-member preview (not public, not click-tracked); the slug is the public, shareable, tracked surface. == COMMON RECIPES == Each is a complete call. Public reads need no token; writes need the scope shown. # Create a short link (mints its first slug) curl -X POST https://.staging.vcurl.co/api/v1/team/links \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"destination_url":"https://example.com/very/long","path":"launch"}' # → {"data":{"id":"","slugs":[{"url":"https:///launch",...}], ...}} # Add another slug to an existing link curl -X POST https://.staging.vcurl.co/api/v1/team/links//slugs \ -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"path":"promo"}' # Create a paste (countdowns/files follow the same shape; files are multipart) curl -X POST https://.staging.vcurl.co/api/v1/team/pastes \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"title":"Release notes","content":"## v2\n\nShipped.","path":"notes"}' == GAMES — SCOREBOARDS, ACHIEVEMENTS & STREAKS == One GAME owns many SCOREBOARDS (score tables) plus shared ACHIEVEMENTS and daily STREAKS. {game} and {board} are UUIDs from the dashboard. Reads are public; writes need a games:submit key. # Read a scoreboard (public — no token) curl "https://.staging.vcurl.co/api/v1/scoreboards/?period=week&username=ACE" # List a game's achievements (public; add ?username= or ?private_identifier= for per-player progress) curl "https://.staging.vcurl.co/api/v1/games//achievements?username=ACE" # Submit a score curl -X POST https://.staging.vcurl.co/api/v1/scoreboards//entries \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"username":"ACE","score":128400,"private_identifier":"device-uuid"}' # score = points, or whole milliseconds on a time board. Returns the entry + its rank. # Unlock an achievement (manual achievements only; {key} e.g. first_win) curl -X POST https://.staging.vcurl.co/api/v1/games//achievements/first_win/unlock \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"username":"ACE","private_identifier":"device-uuid","amount":1}' # Check in a daily streak (idempotent per day; any score/unlock also counts as a check-in) curl -X POST https://.staging.vcurl.co/api/v1/games//streak \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"username":"ACE","private_identifier":"device-uuid"}' # GDPR-erase one player (BY IDENTIFIER ONLY — never username) curl -X DELETE https://.staging.vcurl.co/api/v1/games//player \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"private_identifier":"device-uuid"}' private_identifier is OPTIONAL, write-only, stored hashed — send it consistently so players sharing a username ("god") stay distinct, and so you can fetch/erase exactly one player. == FORMS & POLLS == Public form/poll pages live at their slug and humans post from the browser. To submit from your own client, use the scoped submit keys (embeddable, submit-only): # Create a feedback form curl -X POST https://.staging.vcurl.co/api/v1/team/forms \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"title":"Beta","path":"beta","fields":[{"key":"msg","label":"Message","type":"long_text","required":true}]}' # Submit a form response (forms:submit) — cast a poll vote is POST /polls//votes (polls:submit) curl -X POST https://.staging.vcurl.co/api/v1/forms//submissions \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"answers":{"msg":"Great tool"}}' # Read collected responses / votes with forms:submissions / polls:votes (team-scoped) — see the spec. == USER-SCOPED PRODUCTS: SCROBBLEWITH & SECRETGIFTS == These two are owned by YOU (the token's user), not a team, so they live on the GLOBAL base (https://staging.vcurl.co/api/v1 — there is NO subdomain for them). ScrobbleWith — mirror another Last.fm user's (or a radio station's) plays onto your own Last.fm profile, live. Needs a linked Last.fm account. (mirrors:read/write, scrobbles:read) # Start a mirror (POST extends via PATCH, stop via DELETE; read mirrored tracks at /tracks) curl -X POST https://staging.vcurl.co/api/v1/scrobble/mirrors \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"source_username":"rj","hours":24}' # Also: source_type radio_station (+station) or custom_stream (+stream_url, http(s) only) IS IT STILL WORKING? Two background syncs, each read with scrobbles:read and changed with mirrors:write: GET/PATCH https://staging.vcurl.co/api/v1/scrobble/sync streaming service -> Last.fm health GET/PATCH https://staging.vcurl.co/api/v1/scrobble/favourites-sync favourites, both ways `healthy` is NULL until the monitor has run once — not the same as false. A GET creates nothing, so a listener who has never configured either still gets an answer. Favourites `mode`: "additive" only adds; "mirror" also REMOVES un-favourites on the other side, so it deletes. Triggering a sync, the de-duplicate pass and linking Spotify are dashboard-only — the first two are throttled jobs, the third is an OAuth flow. SecretGifts — run a Secret Santa / gift exchange: create it, invite people, draw & announce matches, then read the giver→receiver assignments. (secretgifts:read/write) # Create an exchange (seeds the 4 default questions) curl -X POST https://staging.vcurl.co/api/v1/secretgifts/exchanges \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"name":"Office Secret Santa","exchange_date":"2026-12-18","budget":20,"currency":"GBP"}' # Invite people (magic-link; existing addresses skipped), then publish and draw the matches curl -X POST https://staging.vcurl.co/api/v1/secretgifts/exchanges//participants \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"people":[{"email":"ann@example.com"},{"name":"Bo","email":"bo@example.com"}]}' curl -X POST https://staging.vcurl.co/api/v1/secretgifts/exchanges//match \ -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"announce":true}' Everything else: GET/PATCH/DELETE .../secretgifts/exchanges/ · GET .../exchanges//participants POST .../exchanges//announce (only if you matched with announce:false) GET .../exchanges//assignments QUESTIONS — what participants are asked when they set their preferences: GET/POST .../exchanges//questions PATCH/DELETE .../exchanges//questions/ Fields: prompt (required), type (short_text|long_text|choice|boolean, default long_text), required, options[] (the choice list), sort_order (omit and it is appended). Addressed ONE AT A TIME, not replaced as a list: an answer cascades away with its question, so DELETING A QUESTION PERMANENTLY DELETES EVERY ANSWER GIVEN TO IT. Confirm with your human first. Frozen once the draw has run — people answered the set that was there. LOCKED AFTER THE DRAW: once matched, only `rules` can still change. A PATCH carrying other fields is not rejected — the rest are SILENTLY DROPPED, so read the exchange back rather than assuming your payload landed. (The MCP tool returns `ignored_locked_fields`; the HTTP API does not.) Deleting an exchange emails everyone who joined that it is cancelled. THE PAIRINGS ARE THE SECRET. As organiser you can read who drew whom — that is the one place it is available. Do not repeat it back to a participant, echo it into a shared channel, or include it in anything the group can see; it is the entire point of the exercise. WEBHOOKS: `exchange.matched` and `exchange.announced` carry the exchange and a headcount only, never the pairing. == GitConcierge — SUPPORT DESKS ON YOUR GIT ISSUES == A team runs SUPPORT DESKS (a public ticket form backed by a repo's issues) and CHANGELOGS (a repo's releases as a public page). Tickets become issues; staff replies sync back to the customer's thread. Needs gitconcierge:read / :write; opening a ticket needs :submit. Desks: GET/POST https://.staging.vcurl.co/api/v1/team/support-desks GET/PATCH/DELETE .../team/support-desks/ POST .../team/support-desks//provision (register the repo webhook + labels; optional) Tickets (read + act; you cannot create one here — see submit below): GET .../team/tickets · GET .../team/tickets/ (includes the public thread) POST .../team/tickets//reply {"body":"..."} → posted to the issue AND emailed to the customer POST .../team/tickets//close · POST .../team/tickets//reopen Changelogs: GET/POST .../team/changelogs · GET/PATCH/DELETE .../team/changelogs/ Open a ticket on someone's behalf (needs a gitconcierge:submit key — NOT read/write): curl -X POST https://.staging.vcurl.co/api/v1/gitconcierge//tickets \ -H "Authorization: Bearer " -H "Content-Type: application/json" \ -d '{"name":"Ada","email":"ada@example.com","answers":{"subject":"Broken","message":"Help"}}' Read a desk's ticket FORM with no token (so you know what `answers` keys it wants): GET https://.staging.vcurl.co/api/v1/gitconcierge/ Keyless changelog read: GET https://.staging.vcurl.co/api/v1/changelogs/ SLUGS: a desk and a changelog share ONE public namespace per team ({team}.gitconcierge.com/{slug}), so a slug held by either is rejected (422) on both. Changing a desk's slug moves its public page AND breaks ticket links already emailed to customers — do not change it unless asked to. REPLY BODIES are markdown, and are profanity-censored to [CENSORED] rather than rejected. == SENDBRAND — BRAND GUIDELINES AS A URL == A team publishes one or more BRANDS (colours, typography, logos, voice, people, naming, legal, social + links) as read-only pages on their own host, each with a machine-readable brief built for you, plus hosted EMAIL SIGNATURES generated from a brand. READ a brand with NO token, directly (permissive CORS) — the fastest way to hand a coding agent a brand: Markdown brief: https://.sendbrand.app//agent (Content-Type: text/markdown) Structured JSON: https://.sendbrand.app/.json (stable `schema_version`) Keyless by UUID: GET https://.staging.vcurl.co/api/v1/brands/ MANAGE brands over the team API (needs a sendbrand:read / sendbrand:write key): GET/POST https://.staging.vcurl.co/api/v1/team/brands · GET/PATCH/DELETE .../team/brands/ Create/patch takes the structured fields (name, slug, colours[{id?,name,hex,dark?,usage?}], type_styles, voice, socials[urls], links[{id?,label,url}], related_brands[{brand,name?}], milestones[{date,title,description?}], awards[{date?,provider?,title,description?,url?}], locations[{id?,label,address?,city?,country?,url?}], emails/phones[{id?,label?,value}] merged into contacts[{id,type:phone|email,label?,value}] for reading). `related_brands[].brand` is a SIBLING BRAND'S UUID — not a numeric id. No read surface emits a numeric id; every identifier on this API is a UUID. A SUPPLIED LIST REPLACES THE STORED ONE WHOLE. Read first, edit the list you got back, send it entire — sending one row deletes the rest. ROW IDs: rows in logos, people, naming, colours, links, locations, emails and phones each carry a stable `id`. SEND IT BACK on every row you are keeping. Two things break if you drop it: a file loses its attachment (below), and any email signature pointing at that row silently falls back to the brand's first entry. Omit a row to delete it; omit the `id` on a new row to have one minted. LOGOS, PEOPLE and NAMING are editable as TEXT, but their FILES (logo artwork, staff photos, pronunciation clips) are uploaded in the dashboard only — you cannot name a file, and one you send is ignored. A logo row with no matching id is dropped (a logo IS its file, so one cannot be created over the API). Image URLs (logos, photos) are short-lived signed CDN links — DOWNLOAD the images and host your own copy; do NOT hotlink them. EMAIL SIGNATURES (same sendbrand:read / sendbrand:write key): GET/POST https://.staging.vcurl.co/api/v1/team/signatures · GET/PATCH/DELETE .../team/signatures/ A signature is mostly POINTERS at a brand: `brand` (UUID) plus name, slug, style (classic|stacked|minimal|card|compact|bold) and six optional row ids — logo_row_id, colour_row_id, link_row_id, location_row_id, email_row_id, phone_row_id. Each names a row `id` read from that brand. Omit one and it falls back to the brand's FIRST entry — except email_row_id / phone_row_id, which show no office contact line at all when unset. Send null to reset a pointer to that default. A pointer naming a row that is not on the chosen brand is REJECTED (422). The response carries `sources` (the pointers as stored) and `resolved` (what they currently produce: accent, logo_url, website, address, office_email, office_phone). The banner IMAGE is dashboard-only, like every other file here; `banner_link_url` is editable. Signatures are generated and copied by a person at the signature's own public URL — there is no endpoint that renders one for you. WEBHOOK: subscribe to `brand.updated` to be told when a guide changes (from the dashboard, the API or MCP). The payload is a pointer plus a `changed` list of field names — fetch `json_url` for the content. Useful for rebuilding design tokens or refreshing a cached style guide. == RULES YOU MUST KNOW == DATES ARE UTC, AND A NON-ZERO OFFSET IS IGNORED — the one that will bite you. Everything is stored and returned in UTC; responses are ISO-8601 with +00:00. CONVERT TO UTC BEFORE YOU SEND. These are all read correctly, and all mean the same instant: "2026-10-20 20:18:00" "2026-10-20T20:18:00Z" "2026-10-20T20:18:00+00:00" A NON-ZERO offset passes validation and is then SILENTLY MISREAD: the offset is dropped and the wall-clock is taken as UTC, so the instant moves by the offset. Worked example — you send "2026-10-20T21:18:00+01:00" (you mean 20:18 UTC) we store 2026-10-20 21:18:00 UTC (an hour later than you meant) So never pass a local time with an offset (expires_at, target_at, starts_on, window starts...). Converting for display is YOUR job; we only deal in UTC. When you are acting FOR AN ACCOUNT HOLDER, read their zone once from GET https://staging.vcurl.co/api/v1/user/me ("timezone", an IANA name like "Europe/London") and render in it — do not assume the server's. When you are building something for MEMBERS OF THE PUBLIC (a countdown page, a poll, a scoreboard) there is no such account, so pick the zone yourself — the visitor's browser, or state the zone next to the time. "17:00" with no zone is a support ticket waiting to happen. LINK SAFETY: every destination URL you send is checked before it is saved, so expect a 422 your input alone does not explain. Two reasons it can be refused: - the host is a private or loopback address (127.*, 10.*, 192.168.*, ::1) or a non-http(s) scheme — a short link is followed by someone else's browser, so it may not point inside their network; - Google Web Risk lists it as malware, phishing or unwanted software. Applies to short-link destinations, routing-rule targets, brand page links, team websites and email-signature banners. Treat the 422 as final — do not retry the same URL, tell your human. It is also re-checked AFTER saving: a destination that is listed later stops forwarding (the visitor sees a "we stopped this link" page, HTTP 403) and the team is emailed. So a link that worked yesterday can legitimately stop today without anyone editing it. PROFANITY: a name / title / label / tag / arcade username is REJECTED (422); a paste body and a countdown/file/game/scoreboard/achievement description is CENSORED to [CENSORED] ("censored":true). RATE LIMITS: public reads ~120/min; public WRITES (score/achievement/streak/form/poll submit) ~30/min PER PLAYER (by private_identifier, else IP), not per key. IMAGE URLS: an icon_url / avatar_url in a response is a signed CDN link that expires after ~24 hours. Fine to hold for a session; re-request the resource for a fresh URL if you cache longer than that. TRUST: a games:submit key ships in your client, so scores are spoofable — use score bounds (min_score/max_score), dashboard moderation and key rotation; validate server-side if it matters. WEBHOOKS (outbound): to be pushed events instead of polling, the team subscribes an HTTPS endpoint in the dashboard (Webhooks). There is NO API to configure endpoints — ask your human to add one and tick the events (bin/email/form/poll/availability/score/achievement/click-milestone, plus GitConcierge ticket.opened / ticket.replied / ticket.closed and SendBrand brand.updated). You only build the receiver. METRICS: each team exposes Prometheus text at GET https://.staging.vcurl.co/metrics (needs a team token with metrics:read; at the host ROOT, not under the API base). The full metric list is in the spec. EXPORT: there is no API export — the account owner runs a full GDPR export from the dashboard. == EVERYTHING ELSE — READ THE SPEC == Update (PATCH) and delete (DELETE) for every object, list filters, pagination, QR codes, analytics rollups, bulk import, routing rules, custom domains, request bins, email bins, site association, and every request/response field are all documented — read it before building beyond these recipes: https://staging.vcurl.co/docs/api.json (browsable UI: https://staging.vcurl.co/docs/api)