Skip to main content

POAP Channels - Technical Integration Guide

This document explains how Spritz integrates POAP (Proof of Attendance Protocol) with public channels: users who hold POAPs can discover event-specific channels, create a channel for a POAP event (one channel per event), and join using Logos Messaging (Waku). It is intended for developers who need to understand or extend the integration.

Overview

  • POAP: Proof of Attendance Protocol; users receive NFTs (POAPs) for attending events. Each POAP is tied to an event (e.g. Devcon 2026).
  • Spritz POAP channels: One public channel can be linked to one POAP event. Only one channel per POAP event is allowed. Channels created from POAPs use Waku/Logos messaging (not the default standard channel backend).
  • User flow: User connects wallet → app fetches their POAPs via POAP API → user sees "From my POAPs" in Browse Channels → for each POAP event they hold, they see either an existing channel (Join/Open) or the option to create the channel. Creating a channel from a POAP auto-sets messaging_type: "waku" and stores poap_event_id, poap_event_name, poap_image_url.
  • Join gating: To join a POAP channel, the user must hold the POAP for that event. The server checks both the user's address and their Smart Wallet (from shout_users.smart_wallet_address) so passkey users can join if the POAP is on their Smart Wallet. If the user does not hold the POAP, POST /api/channels/:id/join returns 403.
  • Multi-address scan: The "From my POAPs" endpoint (/api/poap/events-with-channels) accepts either a single address or multiple addresses (comma-separated). POAPs from all addresses are merged and deduplicated by event id (e.g. identity wallet + Smart Wallet for passkey users).

Prerequisites

RequirementDescription
POAP API keySet POAP_API_KEY in environment. Without it, POAP endpoints return events: [] and a message that the integration is not configured. Get keys at POAP Documentation.
POAP API baseSpritz uses https://api.poap.tech. Scan endpoint: GET https://api.poap.tech/actions/scan/{address} with header X-API-Key: <POAP_API_KEY>.
DatabaseTable shout_public_channels must have columns poap_event_id, poap_event_name, poap_image_url and unique index on poap_event_id (see Database schema).

Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│ POAP Channels Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Wallet (0x...) │
│ │ │
│ ▼ │
│ GET /api/poap/scan?address=0x... GET /api/poap/events-with-channels?... │
│ │ │ │
│ │ X-API-Key: POAP_API_KEY │ (scan + DB channels) │
│ ▼ ▼ │
│ POAP API: /actions/scan/{address} Spritz: shout_public_channels │
│ │ │ │
│ ▼ ▼ │
│ Deduplicated events (eventId, name, imageUrl) Events + channel or null │
│ │ │ │
│ └────────────────┬───────────────────────┘ │
│ ▼ │
│ UI: "From my POAPs" — Create channel or Join existing │
│ │ │
│ ▼ │
│ POST /api/channels { poapEventId, poapEventName, poapImageUrl } │
│ │ │
│ ▼ │
│ One channel per poap_event_id (Waku); creator auto-joined │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Database Schema

POAP-linked channels extend shout_public_channels with three nullable columns and a unique partial index. Migration: migrations/080_poap_channels.sql.

-- POAP-linked channels: one channel per POAP event (e.g. Devcon 2026)
-- Channels created from POAPs use Logos/Waku messaging.

ALTER TABLE shout_public_channels
ADD COLUMN IF NOT EXISTS poap_event_id INTEGER UNIQUE;

ALTER TABLE shout_public_channels
ADD COLUMN IF NOT EXISTS poap_event_name TEXT;

ALTER TABLE shout_public_channels
ADD COLUMN IF NOT EXISTS poap_image_url TEXT;

-- Index for lookup: find channel by POAP event
CREATE UNIQUE INDEX IF NOT EXISTS idx_channels_poap_event_id
ON shout_public_channels(poap_event_id) WHERE poap_event_id IS NOT NULL;

COMMENT ON COLUMN shout_public_channels.poap_event_id IS 'POAP event id from POAP API; at most one channel per event';
COMMENT ON COLUMN shout_public_channels.poap_event_name IS 'Display name of the POAP event (e.g. Devcon 2026)';
COMMENT ON COLUMN shout_public_channels.poap_image_url IS 'POAP artwork URL for the channel icon';
ColumnTypeDescription
poap_event_idINTEGER UNIQUEPOAP event id from POAP API. At most one channel per event.
poap_event_nameTEXTDisplay name of the event (e.g. "Devcon 2026").
poap_image_urlTEXTPOAP artwork URL used as channel icon.

Lookup by POAP event: WHERE poap_event_id = :id and is_active = true.


API Endpoints

1. Scan user POAPs (deduplicated events)

Returns the user's POAP collection as deduplicated events (one entry per POAP event), for use in "create/join POAP channel" UX.

Request

GET /api/poap/scan?address=0x1234...5678
ParameterTypeRequiredDescription
addressstringYesEthereum address (checksummed or lowercase).

No authentication required. Server uses POAP_API_KEY to call POAP API.

Response (200)

When POAP is configured and scan succeeds:

{
"events": [
{
"eventId": 12345,
"eventName": "Devcon 2026",
"imageUrl": "https://..../poap-art.png"
},
{
"eventId": 67890,
"eventName": "EthCC 2025",
"imageUrl": null
}
]
}

When POAP_API_KEY is not set:

{
"error": "POAP integration not configured",
"events": []
}

On POAP API failure the API still returns 200 with events: [] and an error message so the UI can show a friendly state.

Server implementation (concept)

// src/app/api/poap/scan/route.ts
const POAP_API_BASE = "https://api.poap.tech";

export async function GET(request: NextRequest) {
const address = request.nextUrl.searchParams.get("address");
if (!address?.trim()) {
return NextResponse.json(
{ error: "address query parameter is required" },
{ status: 400 }
);
}

const apiKey = process.env.POAP_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "POAP integration not configured", events: [] },
{ status: 200 }
);
}

const url = `${POAP_API_BASE}/actions/scan/${encodeURIComponent(
address.trim()
)}`;
const res = await fetch(url, {
headers: { "X-API-Key": apiKey },
next: { revalidate: 300 },
});

if (!res.ok) {
return NextResponse.json(
{ error: "Failed to fetch POAPs", events: [] },
{ status: 200 }
);
}

const data = await res.json();
const rawList = Array.isArray(data)
? data
: data?.tokens ?? data?.poaps ?? [];
const seen = new Map<
number,
{ eventId: number; eventName: string; imageUrl: string | null }
>();

for (const item of rawList) {
const event = item?.event ?? item;
const eventId =
typeof event?.id === "number" ? event.id : parseInt(event?.id, 10);
if (eventId == null || Number.isNaN(eventId) || seen.has(eventId))
continue;

const eventName =
event?.name ?? event?.event_name ?? `POAP #${eventId}`;
const imageUrl = item?.image_url ?? event?.image_url ?? null;

seen.set(eventId, {
eventId,
eventName: eventName.trim() || `Event ${eventId}`,
imageUrl: imageUrl || null,
});
}

const events = Array.from(seen.values()).sort((a, b) =>
a.eventName.localeCompare(b.eventName)
);

return NextResponse.json({ events });
}

Client example

async function fetchMyPoapEvents(
userAddress: string
): Promise<PoapEventForChannel[]> {
const res = await fetch(
`/api/poap/scan?address=${encodeURIComponent(userAddress)}`
);
const data = await res.json();
if (data.error && data.events?.length === 0) {
console.warn("POAP not configured or scan failed:", data.error);
return [];
}
return data.events ?? [];
}

2. User POAP events with channel status ("From my POAPs")

Returns the same deduplicated POAP events for the user, plus for each event whether a channel already exists and the user's membership. Used by the "From my POAPs" tab in Browse Channels. Supports multi-address scan: pass addresses=0x1,0x2 to merge POAPs from multiple wallets (e.g. identity + Smart Wallet for passkey users).

Request

GET /api/poap/events-with-channels?address=0x1234...5678

Or multiple addresses (POAPs merged, deduplicated by event id):

GET /api/poap/events-with-channels?addresses=0x1234...,0x5678...&memberAddress=0x1234...
ParameterTypeRequiredDescription
addressstringYes*Single wallet address. Use when only one address is needed.
addressesstringYes*Comma-separated wallet addresses. POAPs from all are merged and deduplicated.
memberAddressstringNoAddress used for is_member lookup (default: first address in list).

*Exactly one of address or addresses is required.

Response (200)

{
"events": [
{
"eventId": 12345,
"eventName": "Devcon 2026",
"imageUrl": "https://..../poap-art.png",
"channel": {
"id": "uuid-of-channel",
"name": "POAP: Devcon 2026",
"description": "Community channel for holders of this POAP.",
"poap_event_id": 12345,
"is_member": true
}
},
{
"eventId": 67890,
"eventName": "EthCC 2025",
"imageUrl": null,
"channel": null
}
]
}
  • channel: null means no channel exists for that POAP event yet; the UI can offer "Create channel".
  • channel present: show "Join" or "Open" and use channel.id; is_member indicates if the current user is already a member.

Server implementation (concept)

  1. Resolve addresses: single address or split addresses (comma-separated). Optionally use memberAddress for membership lookup (default: first address).
  2. For each address, call POAP API GET https://api.poap.tech/actions/scan/{address} with X-API-Key; merge results and deduplicate by event id (optionally keep createdAt for sorting).
  3. Query shout_public_channels where is_active = true and poap_event_id in event ids.
  4. Query shout_channel_members for memberAddress to set is_member on each channel.
  5. Return list of { eventId, eventName, imageUrl, channel } (e.g. sorted by createdAt desc when available).
// After fetching POAP list and building seen map (eventId -> { eventName, imageUrl })
const eventIds = Array.from(seen.keys());

const { data: channels } = await supabase
.from("shout_public_channels")
.select("*")
.eq("is_active", true)
.in("poap_event_id", eventIds);

const { data: memberships } = await supabase
.from("shout_channel_members")
.select("channel_id")
.eq("user_address", userAddress.toLowerCase());

const memberSet = new Set(memberships?.map((m) => m.channel_id) ?? []);
const channelByEventId = new Map(
(channels ?? []).map((c) => [
c.poap_event_id,
{ ...c, is_member: memberSet.has(c.id) },
])
);

const eventsList = eventIds
.sort((a, b) => seen.get(a).eventName.localeCompare(seen.get(b).eventName))
.map((eventId) => ({
eventId,
eventName: seen.get(eventId).eventName,
imageUrl: seen.get(eventId).imageUrl,
channel: channelByEventId.get(eventId) ?? null,
}));

return NextResponse.json({ events: eventsList });

3. Get channel by POAP event id

Part of the general channels API: if poapEventId is provided, returns the single active channel linked to that POAP event (or null).

Request

GET /api/channels?poapEventId=12345&userAddress=0x...
ParameterTypeRequiredDescription
poapEventIdnumberYes (for this use case)POAP event id.
userAddressstringNoIf provided, response includes is_member for that user.

Response (200)

{
"channel": {
"id": "uuid",
"name": "POAP: Devcon 2026",
"description": "Community channel for holders of this POAP.",
"emoji": "🎫",
"poap_event_id": 12345,
"poap_event_name": "Devcon 2026",
"poap_image_url": "https://...",
"messaging_type": "waku",
"waku_content_topic": "/spritz/1/channel-...",
"is_member": false
}
}

If no channel exists for that event: { "channel": null }.


4. Join channel (POAP gating)

For POAP channels (channel has poap_event_id), joining is gated: the user must hold the POAP for that event. The server checks both the user's userAddress and their Smart Wallet (shout_users.smart_wallet_address) so passkey users can join if the POAP is on their Smart Wallet.

Request

POST /api/channels/:id/join
Content-Type: application/json

Body

{
"userAddress": "0x..."
}

Verification (POAP channels only)

  1. Load channel; if poap_event_id is set, require POAP_API_KEY.
  2. Call POAP API GET https://api.poap.tech/actions/scan/{address}/{eventId} for each address to check (user address + Smart Wallet if different).
  3. If any address holds the POAP, allow join; otherwise return 403.

Response (200)

{
"success": true,
"channelName": "POAP: Devcon 2026"
}

Errors

StatusCondition
403POAP channel and POAP_API_KEY not set: "POAP verification is not configured. You need this POAP to join this channel."
403POAP channel and user does not hold the POAP: "You need this POAP to join this channel. Hold the POAP in your wallet to join."
400Already a member: "Already a member of this channel."

Server implementation (concept)

// For POAP channels: check ownership via POAP API scan for this event
async function addressHoldsPoap(
address: string,
eventId: number,
apiKey: string
): Promise<boolean> {
const url = `${POAP_API_BASE}/actions/scan/${encodeURIComponent(
address
)}/${eventId}`;
const res = await fetch(url, {
headers: { "X-API-Key": apiKey },
next: { revalidate: 60 },
});
if (!res.ok) return false;
const data = await res.json();
const list = Array.isArray(data) ? data : data?.tokens ?? data?.poaps ?? [];
return list.length > 0;
}

// Addresses to check: user address + Smart Wallet (for passkey users)
const addressesToCheck = [normalizedAddress];
const { data: userRow } = await supabase
.from("shout_users")
.select("smart_wallet_address")
.eq("wallet_address", normalizedAddress)
.maybeSingle();
if (userRow?.smart_wallet_address?.toLowerCase() !== normalizedAddress) {
addressesToCheck.push(userRow.smart_wallet_address.toLowerCase());
}
let hasPoap = false;
for (const addr of addressesToCheck) {
if (await addressHoldsPoap(addr, poapEventId, apiKey)) {
hasPoap = true;
break;
}
}
if (!hasPoap) {
return NextResponse.json(
{
error: "You need this POAP to join this channel. Hold the POAP in your wallet to join.",
},
{ status: 403 }
);
}

5. Create a POAP-linked channel

Creating a channel with poapEventId (and optionally poapEventName, poapImageUrl) creates a POAP channel: one channel per POAP event, Waku/Logos messaging, and default description.

Request

POST /api/channels
Content-Type: application/json
Cookie: (session)

Body

FieldTypeRequiredDescription
poapEventIdnumberYes (for POAP channel)POAP event id from scan/events-with-channels.
poapEventNamestringNoDisplay name (e.g. from POAP API).
poapImageUrlstringNoPOAP artwork URL for channel icon.
namestringNoOverride display name; default uses poapEventName or "POAP".

For POAP channels, messagingType is forced to "waku"; other channel fields (e.g. description, emoji, category) can be sent and are applied.

Example

{
"poapEventId": 12345,
"poapEventName": "Devcon 2026",
"poapImageUrl": "https://..../poap-art.png"
}

Validation

  • If poapEventId is provided and valid, the backend ensures at most one channel per POAP event. If a channel with that poap_event_id already exists, response is 400: "A channel for this POAP already exists" with existingChannelId.
  • Channel name is stored as POAP: ${poapEventName || name || "POAP"} (sanitized).
  • Default description for POAP channels: "Community channel for holders of this POAP."

Response (200)

{
"channel": {
"id": "uuid",
"name": "POAP: Devcon 2026",
"description": "Community channel for holders of this POAP.",
"emoji": "🎫",
"category": "events",
"poap_event_id": 12345,
"poap_event_name": "Devcon 2026",
"poap_image_url": "https://..../poap-art.png",
"messaging_type": "waku",
"waku_symmetric_key": "...",
"waku_content_topic": "/spritz/1/channel-...",
"creator_address": "0x...",
"member_count": 1
}
}

The creator is auto-joined (one row in shout_channel_members).

Client example (useChannels hook)

const createChannel = useCallback(
async (params: {
name?: string;
poapEventId?: number;
poapEventName?: string;
poapImageUrl?: string;
// ... other fields
}) => {
const res = await fetch("/api/channels", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
...params,
creatorAddress: userAddress,
messagingType:
params.poapEventId != null
? "waku"
: params.messagingType || "standard",
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to create channel");
return data.channel;
},
[userAddress]
);

// Create POAP channel from "From my POAPs" selection
await createChannel({
poapEventId: event.eventId,
poapEventName: event.eventName,
poapImageUrl: event.imageUrl ?? undefined,
});

POAP collections (channels by collection)

Spritz also supports POAP collections: channels can be linked to a POAP collection (group of events) instead of a single event. One channel per collection. Join gating applies: users must hold at least one POAP from that collection to join.

List or search collections

GET /api/poap/collections?offset=0&limit=20&query=...
ParameterTypeRequiredDescription
offsetnumberNoPagination offset (default: 0).
limitnumberNoPage size 1–100 (default: 20).
querystringNoSearch term; omit to list all.

Response (200): { collections: [...], nextCursor: string | null }. Each collection: id, title, description, logoImageUrl, bannerImageUrl, dropsCount, year.

Get collection by ID

GET /api/poap/collections/:id

Returns a single collection including dropIds (event ids in the collection). Used to create a channel linked to that collection.

Collections for user (with channel status)

GET /api/poap/collections-for-user?address=0x...&memberAddress=0x...

Or ?addresses=0x1,0x2 for multi-address (e.g. identity + Smart Wallet). Returns POAP collections where the user holds at least one POAP, with linked channel (if any) and is_member. Optional memberAddress for membership lookup (default: first address).

Response (200): { collections: [{ id, title, description, logoImageUrl, bannerImageUrl, dropsCount, year, canJoin: true, channel }] }.

Create channel by collection

POST /api/channels accepts poapCollectionId, poapCollectionName, poapCollectionImageUrl (and optionally name). At most one channel per collection; join gating checks that the user holds at least one POAP from that collection's drops. Database columns: poap_collection_id, poap_collection_name, poap_collection_image_url (see migration 20260126_poap_collections.sql).


End-to-end flow (Browse Channels → "From my POAPs")

  1. User opens Browse Channels and switches to "From my POAPs".
  2. Frontend calls GET /api/poap/events-with-channels?address={userAddress} (with session or passed address).
  3. Backend fetches POAPs from POAP API, deduplicates by event id, then loads channels for those event ids and membership for the user.
  4. UI renders one row per POAP event:
    • If channel == null: show "Create channel" (and on confirm send POST /api/channels with poapEventId, poapEventName, poapImageUrl).
    • If channel present: show "Join" or "Open" using channel.id (existing join channel flow).
  5. After creating a channel, refresh the list (e.g. call events-with-channels again) so the new channel appears.

File structure (eth-akash)

PathPurpose
src/app/api/poap/scan/route.tsGET /api/poap/scan — fetch user POAPs, return deduplicated events.
src/app/api/poap/events-with-channels/route.tsGET /api/poap/events-with-channels — POAP events + channel and membership.
src/app/api/poap/collections/route.tsGET /api/poap/collections — list or search POAP collections.
src/app/api/poap/collections/[id]/route.tsGET /api/poap/collections/:id — get collection by ID (includes dropIds).
src/app/api/poap/collections-for-user/route.tsGET /api/poap/collections-for-user — collections user can join + channel status.
src/app/api/channels/route.tsGET /api/channels (with poapEventId or poapCollectionId) and POST (POAP event or collection body).
src/hooks/useChannels.tscreateChannel with poapEventId, poapEventName, poapImageUrl.
src/components/BrowseChannelsModal.tsx"From my POAPs" tab, calls events-with-channels, create/join.
migrations/080_poap_channels.sqlAdds poap_event_id, poap_event_name, poap_image_url and unique index.
migrations/20260126_poap_collections.sqlAdds poap_collection_id, poap_collection_name, poap_collection_image_url and unique index.

Environment

VariableRequiredDescription
POAP_API_KEYYes (for POAP features)API key for api.poap.tech. See POAP Documentation.

Without POAP_API_KEY, /api/poap/scan and /api/poap/events-with-channels return events: [] and an error message; the rest of the app (non-POAP channels) continues to work.


Next Steps