API & Integrations
Live webhooks when a raffle opens or a project is added, plus the read feed and partner write API.
Three surfaces, one header (X-API-Key). Partner write keys you mint yourself: one user key on your profile (or Dashboard → Keys) — the secret starts with your Tessera username — or a team key (Team → Settings). Event and read-feed keys are still issued by Tessera.
| For | Base | |
|---|---|---|
| Live events | Bots and up-pointers. Tessera POSTs when a raffle goes live or a project is added. | /events/v1 |
| Read feed | Poll raffles, community stats, runners, requirements, winners (CollabOS is on this). | /provider |
| Partner write | Create raffles and pull winners after draw, as a team member. | /partner/v1 |
Authentication
Send your key on every request in the X-API-Key header (Authorization: Bearer <key> works too). Event keys, read-feed keys, and partner keys are separate — ask for the one you need.
Same key for create and winners
The key you mint for POST /partner/v1/raffles also auths the read feed:
GET https://api.tesserapp.org/provider/raffles/{id or slug}
GET https://api.tesserapp.org/provider/raffles/{id or slug}/winners
Partner winners (team-scoped, 409 until drawn):
GET https://api.tesserapp.org/partner/v1/raffles/{id or slug}/winners
GET /provider/ping and GET /events/v1/ping are unauthenticated — connectivity only.
Communities
Public team-page stats. Same read key as the raffle feed. A daily pass is enough. The cap is 60 requests a minute.
Look up one community by its project X:
GET /provider/communities?x=joinsyndicate_
x can be a handle, @handle, or an x.com / twitter.com profile URL. One team per handle. No match is 404. A value that is not a handle is 400.
Walk every community when you do not have the handle yet. Oldest first. cursor is the nextCursor from the previous page. limit defaults to 50 and stops at 100.
GET /provider/communities?limit=50
| Field | Meaning |
|---|---|
xHandle | Project X linked on the team. Null when they have not linked one. |
members | Team members plus the owner. A count. Member handles are not on the public team page. |
raffles | Every raffle on the team. |
live | Raffles still open. |
stats.totalEntries | Entries on raffles whose entry list is public. Same counter as the public lists, so a live raffle can be about an hour ahead of this number. |
stats.totalWinners | Winner rows on those raffles. |
stats.avgEntriesPerRaffle | Rounded. Private entry lists are left out, same as the team page. |
stats.winRate | Winners divided by entries, as a rounded percent. |
statsHidden | The team hid the four stats. stats is null. Members, raffles, and live stay. |
url | https://tesserapp.org/teams/{slug} |
The body includes "source": "tessera".
Live events
HTTPS webhooks. You register a URL; Tessera POSTs JSON the moment a raffle opens or a project first appears. No websocket. If you cannot expose a URL, poll GET /events/v1/feed.
| Event | When | data |
|---|---|---|
raffle.live | A raffle is created — it is live at that moment. | Raffle id, public URL, title, times, winner count, project X, team, requirements. |
project.added | First raffle for that collab project's X handle. The public page /project/{handle} now exists. | Handle, project URL, X URL, first raffle. |
A raffle for a new project fires both events. Treat them as independent POSTs — delivery order is not guaranteed.
Register a webhook
Response includes id, url, events, and secret once. Store the secret. Later GET /events/v1/webhooks does not return it. Max 5 webhooks per key. HTTPS only.
DELETE /events/v1/webhooks/{id} removes it. POST /events/v1/webhooks/{id}/test sends a webhook.ping so you can check the path before a real raffle.
Receive the POST
Tessera POSTs to your URL with a JSON body and these headers:
| Header | Meaning |
|---|---|
X-Tessera-Event | raffle.live or project.added (or webhook.ping on test) |
X-Tessera-Id | Event id (evt_…) — idempotent key |
X-Tessera-Timestamp | Unix seconds |
X-Tessera-Signature | sha256= + HMAC-SHA256 of the raw body with your webhook secret |
Reply 2xx within 8 seconds. Timeouts and non-2xx retry twice. After 20 consecutive failures the webhook is disabled (active: false). We do not follow redirects.
Body
| Prop | Type | Default |
|---|---|---|
id | string | - |
type | 'raffle.live' | 'project.added' | 'webhook.ping' | - |
createdAt | ISO timestamp | - |
data | object | - |
raffle.live data
| Prop | Type | Default |
|---|---|---|
raffleId | string | - |
raffleUrl | string | - |
title | string | - |
status | 'active' | - |
createdAt / startAt | ISO timestamp | - |
endAt | ISO timestamp | - |
winnerCount | number | - |
requiredWalletType | string | null | - |
projectTwitter | string | - |
projectUrl | string | - |
team | object | null | - |
runner | object | - |
requirements | object[] | - |
project.added data
| Prop | Type | Default |
|---|---|---|
handle | string | - |
projectUrl | string | - |
xUrl | string | - |
firstRaffleId | string | - |
firstRaffleUrl | string | - |
title | string | - |
Poll instead
GET /events/v1/feed?since={ISO}&type=raffle.live&limit=50 — same envelopes, oldest first, last ~200 events. Use since as the previous createdAt. Still needs X-API-Key.
This is not a Discord webhook
Team Settings → webhook URL posts raffle embeds into a Discord channel. /events/v1 is for your own bot: you give Tessera an HTTPS endpoint, we POST the JSON above. Discord webhook URLs are the wrong target unless you wrap them yourself.
Read feed
Poll when you need the full raffle list or winners. For “it just went live”, use Live events above.
List raffles
GET /provider/raffles — every raffle on Tessera, newest first, paginated.
| Parameter | Default | Description |
|---|---|---|
status | all | all, active, or ended |
limit | 100 | Page size, max 200 |
offset | 0 | Pagination offset |
updated_since | — | ISO timestamp — only raffles created or announced after this |
Raffle fields
| Prop | Type | Default |
|---|---|---|
raffleId | string | - |
raffleUrl | string | - |
title | string | - |
status | 'active' | 'ended' | - |
createdAt / startAt | ISO timestamp | - |
endAt | ISO timestamp | - |
endedAt | ISO timestamp | null | - |
updatedAt | ISO timestamp | - |
raffleType | 'standard' | - |
entryCount | number | - |
participantCount | number | - |
hostedFor | object | - |
project | object | null | - |
allocation | object | - |
runner | object | - |
requirements | object[] | - |
Hosted by, hosted for, project
The raffle page shows three identities. GET /provider/raffles and GET /provider/raffles/{id|slug} use the same three.
| On the page | Field | What it is |
|---|---|---|
| Hosted by | runner | The person who created the raffle |
| Hosted for | hostedFor | The Tessera community |
| Project | project | The collab project X |
On Farmerz DAO x Ascendant, Hosted by is SUNNIKA (runner), Hosted for is Farmerz Club (hostedFor), and Project is Ascendants @ascendantsgames (project).
project is null when the raffle has no project X. createdBy, managedBy, and drawnBy still mirror runner.
Runner identity
Every raffle carries the actual person who runs it, keyed the way integrations need it:
| Field | Purpose |
|---|---|
runner.userId | Stable Tessera user ID |
runner.displayName | Human-readable name |
runner.discordId | Best key for automatic matching to your workspace members |
runner.discordUsername | Display and fallback identity |
runner.xUserId / runner.xUsername | X identity where connected |
createdBy, managedBy and drawnBy mirror runner — Tessera has a single raffle role (the creator). drawnBy is null until winners are drawn.
Requirements
The requirements array is pass-through from Tessera's own model — map what you need:
| Type | Meaning |
|---|---|
discord_linked | Entrant must link a Discord account |
discord_server | Role / server requirement — carries guildId, guildName, roleIds[], roleNames[] |
twitter_follow | X follow — value is the handle (@user or user) |
twitter_tweet | Alias of twitter_both (like & repost). value is the tweet URL or numeric id. Optional tweetId — filled from value if omitted |
twitter_both | Like & repost the tweet. Same fields as twitter_tweet |
twitter_like | Like the tweet. Same value / tweetId fields |
twitter_retweet | Repost the tweet. Same value / tweetId fields |
twitter_reply | Reply to the tweet. Same value / tweetId fields |
| … | Wallet checks and others pass through the same shape |
Winners
GET /provider/raffles/{raffleId} (or the URL slug) returns the raffle plus:
| Prop | Type | Default |
|---|---|---|
winners | object[] | - |
requiredWalletType | string | null | - |
winnerCount | number | - |
winnersPublic | boolean | - |
Winners are gated by the team's own choice
Winner lists are included only when the hosting team publishes winners. A companion flag (enabled by Tessera) can widen that to all drawn raffles. Winner private codes and private winner notes are never part of the payload.
Errors
| Status | Meaning |
|---|---|
401 | Missing or invalid X-API-Key |
403 | Key cannot use that team (wrong team key, or user key not on that roster) |
404 | Team / raffle does not exist, or the path is unknown |
400 | Bad parameter (e.g. malformed updated_since) |
Rate guidance: paginate with limit ≤ 200 and walk offset; prefer updated_since for incremental polling.
Partner write API
For bots that create raffles and pull winners after draw (not the CollabOS read feed). Auth is X-API-Key.
Two kinds of keys — mint them in the app, secret shown once:
| Kind | Where | Scope |
|---|---|---|
| User | Dashboard → Keys · profile · POST /user/api-keys | Every team you can manage. One key. Secret starts with your Tessera username. |
| Team | Team → Edit → Settings · POST /teams/{teamId}/api-keys | That community only |
A user key on Sanctuary does not fail with a silent 404 on another community you belong to — it works there too. A team key for Sanctuary returns 403 if you send a different teamId. A team that does not exist is 404. A missing route is 404 Not Found.
Mint / list / revoke (session cookie, same as the site):
Create
POST /partner/v1/raffles — same fields as the site create form.
| Field | Default | Notes |
|---|---|---|
title | required | |
endsAt | required | ISO timestamp |
teamId | required for user keys | Must be a team this key can use (GET /teams). Team keys default to their locked team. |
winnerCount | 1 | |
requirements | [] | Same objects as the UI (type, label, value, tweetId, guildId, …). twitter_tweet is stored as twitter_both |
requiredWalletType | "EVM" | EVM / SOL / BTC / ZEC / NEAR / SUI / XMR / null |
autoDrawWinners | false | Ended is not the same as drawn |
postDiscord | false | If true, needs a Discord channel (discord.channelId or templateId with a post channel). Create is live — no extra status/activate call. |
templateId | — | Team template UUID. Copies that template’s Discord channel + requirements when you omit them. Each Follow row includes targetUserId when the account exists. |
discord | — | { guildId, channelId, roleId, winnerChannelId } |
raffleImageUrl / raffleBannerUrl | — | Raffle logo + banner. Aliases: avatarUrl, bannerUrl. |
Follow handles do not have to be looked up on X before create. GET /partner/v1/templates returns targetUserId on each twitter_follow row when Tessera can see the profile. GET /partner/v1/x/lookup?handle= returns { found, handle, userId } with status 200. found: false means that profile page is gone. An X API body of User not found is not a reason to skip the community — post the raffle with the handle, or with targetUserId when it is set. Create still saves the follow if that id is missing.
Status
GET /partner/v1/raffles/{id} is safe to poll anytime. It returns status (active / ended), endsAt, drawn (bool), announcedAt, entryCount. No winner list. No entries.
Winners
GET …/winners and …/winners.csv return the drawn Winner table only.
- While the raffle is live:
409{ "detail": { "ready": false, "reason": "not_ended" } } - Ended, but winners are still pending (pre-draw) or there are none:
409not_drawn - A short draw still returns. If the raffle ended and every saved winner is confirmed, you get that list even when fewer people won than
winnerCount - Never returns in-progress entrants as winners
- The public read feed (
GET /provider/raffles/{id}/winners) stays empty until winners are announced or the host marks them public. Partner winners do not wait for that announce walletis the payout address for the raffle’s currentrequiredWalletType. If the chain changes after people enter (EVM → SOL),walletis the SOL address, not the0xsaved at entry. The address they entered is kept only when it still matches that chain.- Each winner also includes the profile wallets:
walletAddress(EVM),solWalletAddress,btcWalletAddress,zecWalletAddress,nearWalletAddress,suiWalletAddress,xmrWalletAddress. The read feed (GET /provider/raffles/{id}/winners) uses the same fields. Sui addresses are0xplus 64 hex. Monero addresses are mainnet, start with4or8, and keep their original case. - The JSON body includes
requiredWalletType.
CSV columns: rank, username, discordId, xHandle, wallet, tierName, status, then the profile wallet columns (walletAddress, solWalletAddress, btcWalletAddress, zecWalletAddress, nearWalletAddress, suiWalletAddress, xmrWalletAddress). wallet is the payout column.