API and webhooks

Read your server's cases, tickets and appeals, and hear about them as they happen. Part of Quark Enterprise.

Tokens

A server manager makes API tokens in the dashboard, under Settings › API tokens. A token belongs to one server. Admin and Basic tokens read everything here. A Read only token reads only what it was given: cases, tickets, appeals, or any of them. A token is shown once. Keep it on your server, never in a browser or an app.

curl https://quark.bot/api/v1/tickets?status=open \
  -H "Authorization: Bearer your_token"

A token stops working when it is revoked, or when the server no longer has Quark Enterprise.

Limits

120 requests a minute for each token. Every answer says where you are:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1793635260

Past the limit the answer is 429 with Retry-After, in seconds.

Pages

Lists are newest first. Each answer has data and nextCursor. Pass nextCursor back as cursor for the next page; it is null on the last one. A cursor only works on the list it came from.

{
  "data": [ ... ],
  "nextCursor": "dGlja2V0czo0MTI"
}

Cases

GET/api/v1/casescases:read

Bans, kicks, timeouts and warns, and their reversals, as far back as the server's plan keeps.

ParameterWhat it does
memberOnly cases about this Discord user ID
after, beforeUnix seconds
limit1 to 100. 50 if left out
cursorThe nextCursor from the last page
botpro for Quark Pro's records

GET/api/v1/cases/:caseNocases:read

One case by its number, the one Quark shows in the modlog and in /profile.

ParameterWhat it does
botpro for Quark Pro's records
{
  "caseNo": 148,
  "kind": "ban",
  "logType": 39,
  "at": 1793634000,
  "member": { "id": "1029384756473829101", "name": "nightowl_77" },
  "moderator": { "id": "1029384756473829102", "name": "Mara" },
  "reason": "Scam links",
  "edited": false,
  "modlogMessageId": "1302938475610293847"
}

Tickets

GET/api/v1/ticketstickets:read

Every ticket, newest first.

ParameterWhat it does
statusopen or closed
openerOpened by this Discord user ID
claimedByClaimed by this Discord user ID
limit1 to 100. 50 if left out
cursorThe nextCursor from the last page
botpro for Quark Pro's records

GET/api/v1/tickets/:ticketNotickets:read

One ticket by its number.

ParameterWhat it does
transcript1 to add its messages, oldest first, while they are kept
{
  "ticketNo": 412,
  "status": "closed",
  "category": { "id": "17", "name": "Bug report", "kind": "bug" },
  "openerId": "1029384756473829101",
  "threadId": "1302938475610299999",
  "claimedBy": "1029384756473829102",
  "members": [],
  "form": [{ "label": "What happened?", "value": "Crash on load" }],
  "openedAt": 1793630000,
  "firstResponseAt": 1793630420,
  "firstResponseBy": "1029384756473829102",
  "closedAt": 1793634000,
  "closedBy": "1029384756473829102",
  "closeReason": "Fixed in 1.4",
  "reopened": 0,
  "rating": 5,
  "ratingComment": null,
  "lastActivityAt": 1793634000
}

A transcript message has id, source (discord, slack, teams, zendesk, freshdesk, helpshift, jira, linear or system), author with id, name, company and staff, content, attachments, at, editedAt and deletedAt.

Appeals

GET/api/v1/appealsappeals:read

Waiting appeals come oldest first, the order they are answered in. The rest come newest first.

ParameterWhat it does
statuswaiting, decided or all. all if left out
limit1 to 100. 50 if left out
cursorThe nextCursor from the last page
botpro for Quark Pro's records

Errors

An error has a status and a body that says what to fix:

{ "error": { "code": "missing_scope", "message": "This token does not have tickets:read" } }
400bad_limit, bad_cursor, bad_member, bad_status, bad_time
401unauthorized
403missing_scope, needs_enterprise
404not_found
429rate_limited
503unavailable

Webhooks

Add an https address in Settings › API and webhooks and pick its events. Quark sends a POST with a JSON body when one happens:

  • case.created A ban, kick, timeout or warn
  • appeal.decided An appeal is accepted or denied
  • ticket.opened A member opens a ticket
  • ticket.closed A ticket is closed
  • review.decided A review item is decided
  • raid.detected Quark sees a raid
POST /your/endpoint
Content-Type: application/json
Quark-Event: ticket.opened
Quark-Signature: t=1793630000,v1=5f2c...

{
  "id": "88213",
  "event": "ticket.opened",
  "guildId": "1029384756473829100",
  "at": 1793630000,
  "data": { "ticketNo": 412, "openerId": "1029384756473829101", ... }
}

Answer with any 2xx within 10 seconds. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then each day, 8 tries in all. The same event keeps its id on every try, so use it to skip repeats. Quark doesn't follow redirects.

Send test, beside each webhook, sends an event called test.

Signatures

Each webhook has a signing secret, shown once when it is made. Quark-Signature has the time it was sent (t) and an HMAC-SHA256 of the time, a full stop and the raw body (v1). Check it before you trust the body, and refuse one more than 5 minutes old.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, header, rawBody) {
  const { t, v1 } = Object.fromEntries(
    header.split(",").map((part) => part.split("=")),
  );
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  return (
    expected.length === v1.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
  );
}

IDs and times

Discord IDs are strings, so nothing loses digits. Times are unix seconds, UTC.