Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: AgendaForge Public REST Jobs API
version: 1.0.0
summary: 'Server-to-server REST API for the AgendaForge event-management platform: webhooks (REST Hooks), sample records, cursor-paginated list endpoints, create actions and batch create for contacts, sessions, and sponsors, and async contact and ticket imports with pollable jobs.'
description: 'The HTTP API that powers the AgendaForge Zapier, Make.com, and n8n integrations, and any custom server-to-server integration.
Every request is authenticated with an organization API key (created in Settings > API Tokens; keys begin with afk_live_ and are shown once). Keys carry named scopes chosen at creation: public_api (this API, on by default), mcp:read and mcp:write (the AgendaForge MCP server for AI clients), and tickets:import (ticket imports, never on by default). Every endpoint here requires the public_api scope, except POST /api/v1/imports/tickets, which requires tickets:import; requests with a key that lacks the required scope receive 403 FORBIDDEN.
All requests and responses are JSON. Every error, 4xx and 5xx alike, is JSON of the shape { "error": { "code": "...", "message": "..." } } with a machine-readable code and a human-readable resolution hint. Unknown paths under /api/v1 return 404 NOT_FOUND with the list of valid endpoints.
Pagination: list endpoints (GET /contacts, GET /sessions, GET /sponsors) are cursor-based. Request a page with ?limit= (1-100, default 25), then follow nextCursor via ?cursor= until hasMore is false. Cursors are opaque continuation tokens; never construct one.
Long-running work: POST /api/v1/imports/contacts answers 202 Accepted with a job id and a statusUrl (also in the Location header); poll GET /api/v1/jobs/{id} until status reaches completed or failed. For up to 100 items in one synchronous call, use POST /api/v1/contacts/batch instead. POST /api/v1/imports/tickets finishes up to 100 tickets inside the request (200, job already completed) and queues 101 to 1,000 tickets (202, poll the same way).
Rate limits: 120 requests per minute per API key (fixed window), and 30 requests per minute per IP for unauthenticated requests. Every response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the window resets), and RateLimit-Policy headers so clients can self-throttle; exceeding the window returns 429 RATE_LIMITED with a Retry-After header.
Versioning and deprecation: the API is versioned in the URL path (/api/v1). Additive changes (new endpoints, new optional fields, new webhook events) happen within a version and are never breaking; breaking changes ship only as a new version path. If an endpoint or version is ever deprecated it will be announced in the changelog at least 180 days before removal and signaled with Deprecation and Sunset headers on affected responses. Nothing is deprecated today.
Human-readable reference: https://agendaforge.app/docs/api. Developer portal: https://agendaforge.app/developers. Site content for agents: https://agendaforge.app/llms.txt.'
contact:
name: AgendaForge support
email: hello@agendaforge.app
url: https://agendaforge.app/docs/api
termsOfService: https://agendaforge.app/terms
servers:
- url: https://api.agendaforge.app
description: Production
security:
- bearerApiKey:
- public_api
- headerApiKey:
- public_api
tags:
- name: Jobs
description: 'Async work: 202 Accepted imports and the pollable job resource.'
paths:
/api/v1/imports/contacts:
post:
security:
- bearerApiKey:
- public_api
- headerApiKey:
- public_api
x-required-scopes:
- public_api
operationId: importContacts
tags:
- Jobs
summary: Import up to 1,000 contacts asynchronously
description: 'Async job pattern: accepts the items, returns 202 Accepted immediately with a job id and statusUrl (also sent as the Location header), and processes the import in the background. Poll GET /api/v1/jobs/{id} until status is completed or failed; the job result carries created/failed counts and up to 50 per-item errors. Items behave exactly like single POST /api/v1/contacts requests.'
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ImportContactsRequest'
responses:
'202':
description: The job was accepted. Poll statusUrl (also in the Location header) for progress.
headers:
Location:
description: The poll URL, identical to statusUrl in the body.
schema:
type: string
format: uri
content:
application/json:
schema:
$ref: '#/components/schemas/ImportJobAccepted'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/IdempotencyConflict'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/InternalError'
default:
$ref: '#/components/responses/UnexpectedError'
/api/v1/imports/tickets:
post:
security:
- bearerApiKey:
- tickets:import
- headerApiKey:
- tickets:import
x-required-scopes:
- tickets:import
operationId: importTickets
tags:
- Jobs
summary: Add, update, or cancel up to 1,000 imported tickets
description: 'Sends ticket rows from your own ticketing system to one event, with the same rules as a file import in the app. Requires the tickets:import scope (public_api alone is not enough). Each row is matched by reference: a new reference creates a ticket and needs a name (name, or firstName and lastName) and a ticketType; a reference that already exists may carry only what changed. Rows not in the request are left alone. The event must be activated and set up for imported tickets, and only one import runs per event at a time. Up to 100 tickets are applied inside the request and answer 200 with the job already completed; 101 to 1,000 tickets answer 202 Accepted with a statusUrl (also sent as the Location header) to poll at GET /api/v1/jobs/{id}. Rows fail independently: a row problem counts toward result.failed and is listed in result.errors (first 50) without stopping the other rows. Whole-request problems (an unreadable body, a bad field type, duplicate or too-short scanKey values, more than 50 ticket types) answer 400 and nothing is imported. Besides the per-key limit, each event accepts 12 ticket import requests per minute. With an Idempotency-Key, a repeat of the same request returns the original response (status, body, and Location) with Idempotent-Replay: true, not current progress; poll statusUrl for that.'
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
description: A JSON object of at most 2 MB.
content:
application/json:
schema:
$ref: '#/components/schemas/ImportTicketsRequest'
examples:
add:
summary: Add a ticket
value:
eventId: k17e8m2q5n9p3r6s1t4v
tickets:
- reference: b7c1e0aa-0000-4000-8000-00000000a001
name: Ana Okafor
email: ana@forgelabs.example
company: Forge Labs
title: Head of Platform
ticketType: General
status: valid
scanKey: 5d2f9c10-0000-4000-8000-00000000b001
cancel:
summary: Cancel a ticket (send only the reference and what changed)
value:
eventId: k17e8m2q5n9p3r6s1t4v
tickets:
- reference: b7c1e0aa-0000-4000-8000-00000000a001
status: cancelled
responses:
'200':
description: 'Up to 100 tickets: the import finished inside the request. The job is completed and result carries the final counts.'
content:
application/json:
schema:
$ref: '#/components/schemas/TicketsImportJob'
example:
jobId: jb7q2m5k8n1r4s7t0v3x
type: tickets_import
status: completed
total: 1
statusUrl: https://api.agendaforge.app/api/v1/jobs/jb7q2m5k8n1r4s7t0v3x
result:
total: 1
processed: 1
created: 1
updated: 0
unchanged: 0
cancelled: 0
restored: 0
failed: 0
errors: []
'202':
description: '101 to 1,000 tickets: the import was accepted and runs in the background. Poll statusUrl (also in the Location header) until status is completed or failed.'
headers:
Location:
description: The poll URL, identical to statusUrl in the body.
schema:
type: string
format: uri
content:
application/json:
schema:
$ref: '#/components/schemas/TicketsImportJob'
'400':
$ref: '#/components/responses/TicketImportValidationError'
'401':
$ref: '#/components/responses/Unauthenticated'
'402':
$ref: '#/components/responses/PaymentRequired'
'403':
$ref: '#/components/responses/TicketImportForbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/TicketImportConflict'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/InternalError'
default:
$ref: '#/components/responses/UnexpectedError'
/api/v1/jobs/{id}:
get:
security:
- bearerApiKey:
- public_api
- headerApiKey:
- public_api
- bearerApiKey:
- tickets:import
- headerApiKey:
- tickets:import
x-required-scopes:
- public_api
- tickets:import
operationId: getJob
tags:
- Jobs
summary: Poll an async job
description: Returns the current status and result counters of a job created by POST /api/v1/imports/contacts or POST /api/v1/imports/tickets. A contacts_import job needs a key with public_api; a tickets_import job needs a key with tickets:import. Jobs are retained for 7 days; unknown, expired, and cross-organization ids all answer 404. Poll with backoff (a 1,000-item import typically completes within a minute).
parameters:
- name: id
in: path
required: true
description: The jobId returned by the 202 response.
schema:
type: string
responses:
'200':
description: The job with live progress counters. The shape depends on the job type.
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/Job'
- $ref: '#/components/schemas/TicketsImportJob'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/Unauthenticated'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/InternalError'
default:
$ref: '#/components/responses/UnexpectedError'
components:
schemas:
CreateContactRequest:
type: object
required:
- firstName
- lastName
- email
properties:
type:
type: string
description: speaker | sponsor | attendee | vendor | partner | exhibitor | other. Defaults to "attendee".
firstName:
type: string
lastName:
type: string
email:
type: string
format: email
phone:
type: string
company:
type: string
jobTitle:
type: string
Error:
type: object
required:
- error
description: Every non-2xx response carries this JSON shape. The code is machine-readable; the message is a human-readable resolution hint.
properties:
error:
type: object
required:
- code
- message
properties:
code:
type: string
enum:
- UNAUTHENTICATED
- FORBIDDEN
- VALIDATION_ERROR
- PAYMENT_REQUIRED
- NOT_FOUND
- RATE_LIMITED
- CONFLICT
- INTERNAL
message:
type: string
Job:
type: object
required:
- jobId
- type
- status
- createdAt
- completedAt
- result
properties:
jobId:
type: string
type:
type: string
enum:
- contacts_import
status:
type: string
enum:
- pending
- processing
- completed
- failed
description: Terminal states are completed and failed; keep polling until one is reached.
createdAt:
type: string
description: ISO-8601 UTC timestamp.
completedAt:
type:
- string
- 'null'
description: ISO-8601 UTC timestamp; null until the job reaches a terminal state.
result:
type: object
required:
- total
- processed
- created
- failed
- errors
properties:
total:
type: integer
processed:
type: integer
description: Items handled so far; equals total when done.
created:
type: integer
failed:
type: integer
errors:
type: array
description: Per-item failures (first 50), each with the failing input index.
items:
type: object
required:
- index
- code
- message
properties:
index:
type: integer
code:
type: string
message:
type: string
ImportJobAccepted:
type: object
required:
- jobId
- type
- status
- total
- statusUrl
properties:
jobId:
type: string
description: Opaque job id.
type:
type: string
enum:
- contacts_import
status:
type: string
enum:
- pending
total:
type: integer
description: Items accepted for processing.
statusUrl:
type: string
format: uri
description: Poll this URL (GET, same authentication) for progress.
ImportTicketsRequest:
type: object
required:
- eventId
- tickets
properties:
eventId:
type: string
description: The event to import into; must be owned by the key's organization, activated, and set up for imported tickets.
tickets:
type: array
minItems: 1
maxItems: 1000
items:
$ref: '#/components/schemas/TicketImportRow'
confirmCheckedInCancels:
type: boolean
default: false
description: A ticket that has already been checked in is cancelled or refunded only when this is true. Otherwise that row fails with the checked_in code.
TicketImportRow:
type: object
required:
- reference
description: One ticket. Unknown fields are ignored. A new reference needs a name (name, or firstName and lastName) and a ticketType; a reference that already exists may carry only what changed. Row problems fail only that row.
properties:
reference:
type: string
maxLength: 128
description: Your system's ticket or order line id. It is how a later request updates or cancels the same ticket, so keep it stable.
name:
type: string
description: Full name; split into first and last name. Ignored when firstName or lastName is sent. 120 characters at most.
firstName:
type: string
lastName:
type: string
email:
type: string
format: email
maxLength: 254
company:
type: string
maxLength: 160
title:
type: string
maxLength: 160
description: Job title.
ticketType:
type: string
maxLength: 80
description: Ticket type name. An unknown name creates the type. At most 50 different values per request.
status:
type: string
description: 'Case-insensitive. Valid: valid, active, paid, complete, completed, confirmed, attending, issued. Cancelled: cancelled, canceled, void, voided. Refunded: refunded, refund. Any other word fails the row with unknown_status.'
table:
type: string
maxLength: 40
seat:
type: string
maxLength: 20
scanKey:
type: string
minLength: 8
maxLength: 128
description: Optional. The value inside the attendee's existing QR code, so it scans at the door. Must be unique within the event. Stored only as a hash.
purchasedAt:
type:
- string
- number
description: Purchase date as a date string (e.g. 2026-11-04) or a millisecond timestamp.
answers:
type: array
maxItems: 12
description: Registration answers to keep with the ticket.
items:
type: object
required:
- label
- value
properties:
label:
type: string
description: Question label; up to 120 characters are kept.
value:
type: string
description: Answer; up to 300 characters are kept.
TicketsImportJob:
type: object
required:
- jobId
- type
- status
- total
- statusUrl
- result
description: Returned by POST /api/v1/imports/tickets and by GET /api/v1/jobs/{id} for a tickets_import job.
properties:
jobId:
type: string
description: Opaque job id.
type:
type: string
enum:
- tickets_import
status:
type: string
enum:
- pending
- processing
- completed
- failed
description: completed in a 200 response; pending in a 202 response. Terminal states are completed and failed; keep polling until one is reached.
total:
type: integer
description: Tickets in the request.
statusUrl:
type: string
format: uri
description: Poll this URL (GET, a key with tickets:import) for progress.
result:
type: object
required:
- total
- processed
- created
- updated
- unchanged
- cancelled
- restored
- failed
- errors
properties:
total:
type: integer
processed:
type: integer
description: Rows handled so far, failed rows included; equals total when done.
created:
type: integer
description: New tickets.
updated:
type: integer
description: Existing tickets that changed.
unchanged:
type: integer
description: Existing tickets with nothing to change.
cancelled:
type: integer
description: Tickets cancelled or refunded.
restored:
type: integer
description: Cancelled or refunded tickets made valid again.
failed:
type: integer
description: Rows not applied.
errors:
type: array
description: Row failures (first 50). code is one of missing_reference, reference_too_long, reference_mangled, reference_reserved, duplicate_reference, missing_name, invalid_email, missing_ticket_type, unknown_status, scan_key_reserved, scan_key_length, scan_key_taken, invalid_date, field_too_long, missing_fields, checked_in, event_full, type_catalogue_full. A job that stops early reports one CONFLICT entry and status failed.
items:
type: object
required:
- index
- reference
- code
- message
properties:
index:
type: integer
description: Zero-based position in the tickets array.
reference:
type:
- string
- 'null'
description: The row's reference, or null when it is withheld.
code:
type: string
message:
type: string
ImportContactsRequest:
type: object
required:
- contacts
properties:
contacts:
type: array
minItems: 1
maxItems: 1000
items:
$ref: '#/components/schemas/CreateContactRequest'
responses:
TicketImportValidationError:
description: The body is not a JSON object of at most 2 MB, eventId is missing, tickets is not an array of 1 to 1,000 objects, a field has the wrong type, or the rows break a whole-request rule (duplicate or too-short scanKey values, more than 50 ticket types, more than 12 answers or an over-long value in a row). Nothing was imported.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: VALIDATION_ERROR
message: Send between 1 and 1,000 tickets in one request.
PaymentRequired:
description: The event is not activated yet. Ticket imports need an activated event.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: PAYMENT_REQUIRED
message: This event isn't activated yet. Contact us to activate it for publishing, registrations, and AI.
TicketImportForbidden:
description: The key lacks the tickets:import scope, or the event does not belong to the key's organization.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: FORBIDDEN
message: This API key cannot import tickets. Create a key with the ticket import permission.
ValidationError:
description: A required field is missing or invalid, an unknown event was supplied, or a referenced record is not owned by the key's organization.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: VALIDATION_ERROR
message: firstName, lastName and email are required.
TicketImportConflict:
description: Another import is already running for this event, the event is not set up for imported tickets, or the Idempotency-Key was already used for a different endpoint or a different request body.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: CONFLICT
message: An import is already running for this event. Wait for it to finish.
IdempotencyConflict:
description: The Idempotency-Key was already used on a different endpoint within the 24 hour retention window.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: CONFLICT
message: This Idempotency-Key was already used on a different endpoint. Use a new key.
Unauthenticated:
description: The API key is missing, malformed, invalid, expired, or revoked.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: UNAUTHENTICATED
message: 'Missing API key. Send ''Authorization: Bearer <key>''.'
UnexpectedError:
description: Any other failure, including 404 NOT_FOUND for an unknown path and transient 5xx errors. Always the same JSON error contract.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: INTERNAL
message: Unexpected server error. Retry with exponential backoff; if it persists, contact hello@agendaforge.app.
InternalError:
description: An unexpected server failure. The body still follows the JSON error contract; retry with exponential backoff.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: INTERNAL
message: Unexpected server error. Retry with exponential backoff; if it persists, contact hello@agendaforge.app.
RateLimited:
description: The fixed window for this API key (120 requests per minute) or IP (30 per minute, unauthenticated) is exhausted. Wait Retry-After seconds and retry.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: RATE_LIMITED
message: Rate limit exceeded. Retry after the number of seconds in the Retry-After header.
headers:
Retry-After:
description: Seconds to wait before retrying (RFC 6585).
schema:
type: string
RateLimit-Limit:
description: Requests allowed per window. Sent on every /api/v1 response, not only 429s.
schema:
type: string
RateLimit-Remaining:
description: Requests left in the current window.
schema:
type: string
RateLimit-Reset:
description: Seconds until the current window resets.
schema:
type: string
RateLimit-Policy:
description: The policy as "<limit>;w=<window seconds>", e.g. "120;w=60".
schema:
type: string
NotFound:
description: No such endpoint under /api/v1. The message lists the valid endpoints.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: NOT_FOUND
message: No such endpoint. See https://agendaforge.app/openapi.json for the API surface.
Forbidden:
description: The API key is valid but does not include the public_api scope. Create a key with the public_api scope in Settings > API Tokens.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error:
code: FORBIDDEN
message: This API key does not include the "public_api" scope. Create a key with the public_api scope in Settings > API Tokens.
parameters:
IdempotencyKey:
name: Idempotency-Key
in: header
required: false
description: 'Optional key for safe retries, retained for 24 hours. Repeating the same request on the same endpoint returns the original response and sets Idempotent-Replay: true. Reusing the key on a different endpoint returns 409.'
schema:
type: string
minLength: 1
maxLength: 255
securitySchemes:
bearerApiKey:
type: http
scheme: bearer
bearerFormat: afk_live_ organization API key
description: 'Organization API key sent as "Authorization: Bearer afk_live_...". Keys are created in Settings > API Tokens with named scopes (see x-api-key-scopes): public_api grants this REST API; mcp:read and mcp:write grant the AgendaForge MCP server; tickets:import grants ticket imports. Every endpoint here requires the public_api scope except POST /api/v1/imports/tickets, which requires tickets:import.'
x-scopes:
- public_api
- mcp:read
- mcp:write
- tickets:import
headerApiKey:
type: apiKey
in: header
name: X-API-Key
description: The same afk_live_ organization API key sent as an X-API-Key header instead of a Bearer token. Identical scope semantics to bearerApiKey.
x-scopes:
- public_api
- mcp:read
- mcp:write
- tickets:import
externalDocs:
description: AgendaForge API reference (human-readable)
url: https://agendaforge.app/docs/api
x-api-key-scopes:
- name: public_api
description: Grants the public REST API v1 (this specification). On by default when a key is created. Required by every endpoint here except POST /api/v1/imports/tickets; a key without it receives 403 FORBIDDEN.
- name: mcp:read
description: Grants read tools on the AgendaForge MCP server for AI clients (events, sessions, contacts, forms). MCP keys act as the admin who created them and are re-checked against live membership on every call.
- name: mcp:write
description: 'Grants write tools on the AgendaForge MCP server. Every write is approval-gated: the AI proposes, a person approves.'
- name: tickets:import
description: Grants POST /api/v1/imports/tickets and polling its jobs at GET /api/v1/jobs/{id}. Labeled "Import tickets" in Settings > API Tokens. Never on by default and never implied by another scope; it does not grant the other endpoints here.