Disguild API v1 and webhooks

Read your own listings, reviews and analytics, and get signed webhooks when things happen. Create keys and webhooks in the API dashboard (sign in with Discord).

Authentication

Send Authorization: Bearer ga_live_.... A key is shown once when you create it. Only a hash is stored, so a lost key cannot be recovered: rotate it. Revoking or rotating works on the next request. You can have 5 active keys.

ScopeAllows
listings:readGET /api/v1/listings, GET /api/v1/listings/{id}
reviews:readGET /api/v1/listings/{id}/reviews
analytics:readGET /api/v1/listings/{id}/stats?days=7, 30 or 90

A key only sees listings owned by its owner. Someone else's listing id returns 404, the same as a missing one.

Limits and errors

60 requests per minute per key. Every response carries x-request-id, x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (seconds). A 429 adds retry-after.

Errors look like { "error": { "code", "message", "requestId" } }. Codes: unauthorized 401, invalid_api_key 401, insufficient_scope 403, not_found 404, bad_request 400, rate_limited 429, unavailable 503.

Lists accept ?page=1&limit=25 (limit 1 to 50) and return { data, page, limit, total, has_more }. Single items return { "data": {...} }.

Endpoints

curl -H "Authorization: Bearer $GA_KEY" https://disguild.pages.dev/api/v1/listings

Webhooks

Up to 3 webhooks per user. Each receives the events it subscribes to for all of your listings. No voter or reviewer identity and no review text is ever sent.

EventFired when
listing.approvedA listing is approved, or restored after suspension
listing.rejectedA listing is rejected
listing.updatedAn edit to an approved listing was approved and is now live (no text sent)
review.createdA new review is posted (rating only, no text)
vote.createdA vote is accepted (new vote count only)

Example body:

{ "id": "evt_...", "type": "vote.created", "created_at": "ISO", "data": { "listing": { "id", "type", "slug", "name" }, "vote_count": 12 } }

Headers and signature

x-disguild-id (the same on every retry), x-disguild-event, x-disguild-timestamp (unix seconds, refreshed on each attempt) and x-disguild-signature: v1=<hex>. The hex is HMAC-SHA256 of "<id>.<timestamp>.<raw body>" using your whsec_... secret. Verify against the raw body bytes.

import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, headers, rawBody) {
  const ts = headers['x-disguild-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay protection
  const expected = 'v1=' + createHmac('sha256', secret)
    .update(`${headers['x-disguild-id']}.${ts}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(headers['x-disguild-signature'] ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}

Idempotency: delivery is at least once. Store processed x-disguild-id values and ignore repeats.

Delivery and retries

Any 2xx is success. Anything else (non-2xx, an 8 second timeout, a network error) is retried with backoff at 30 s, 1 m, 2 m, 4 m and 8 m, up to 6 attempts, then marked dead. Redirects are not followed. After 5 dead deliveries in a row the webhook is disabled; any success resets the count. You can pause, resume, rotate the secret, delete and send a test delivery from the dashboard. Expect up to about a minute of delay. Delivery history is kept 30 days.

Allowed destinations

Webhook URLs must be https on port 443, with a public hostname (no IP addresses, no credentials, no localhost or internal-style names). Every address the name resolves to must be public, and this is checked again before each delivery.