Map D Developers
External API
A signed HTTP API for reading and writing show data — attendees, speakers, schedules, sponsors, and exhibitors. Every request is authenticated with an HMAC-SHA256 signature derived from your API key pair. This guide covers everything you need to make your first call.
Getting started
Create an API key pair in your workspace at Admin → API Settings. This requires the API-keys-manage permission.
Each key has two parts. The public key (mdpk_v1_…) identifies your integration and is safe to log. The secret (mdsk_v1_…) is shown once, at creation — copy it immediately and store it somewhere secure. If you lose it, revoke the key and create a new one.
Authentication & signing
Every request must carry these four headers:
| Header | Description |
|---|---|
x-md-key | Public API key. Format: mdpk_v1_ followed by 24 lowercase hex characters. |
x-md-timestamp | Unix epoch time in seconds (integer). Must be within ±5 minutes of server time. |
x-md-nonce | Single-use lowercase hex string, at least 16 characters. Reuse is rejected. |
x-md-signature | HMAC-SHA256 of the canonical string, as 64 lowercase hex characters. |
Server-side only — never sign in the browser. Your secret is a bearer credential. Sign requests exclusively in a trusted server environment. Do not bundle the secret into front-end code, a mobile app, or a public repository.
The canonical string
Build a UTF-8 string from the following five fields, joined by a single newline (\n), in this exact order:
- HTTP method — Uppercased, e.g. POST.
- Path + query — Exactly as sent, no normalization, e.g. /api/external/v1/shows/demo/attendees?page=2.
- Timestamp — The x-md-timestamp value.
- Nonce — The x-md-nonce value.
- Body hash — Lowercase hex SHA-256 of the raw request body bytes (SHA-256 of the empty string when there is no body).
The signing key is the raw bytes of SHA-256(your secret). The signature is HMAC-SHA256(signingKey, canonicalString) rendered as lowercase hex.
The server rejects requests whose timestamp is outside a ±5 minutes window from its own clock, and rejects any nonce it has seen before. Generate a fresh nonce per request and keep your clock in sync (e.g. NTP).
Signing client
You do not have to implement the signing scheme by hand. The mapd-api-client.js module below is a dependency-free, server-side signing client for Node.js ≥ 18. Copy or download it into your project and use it as-is — it always produces signatures the API accepts.
/**
* MAPD External API — Signing Client (distributable)
* ==================================================
*
* A single, self-contained JavaScript module that signs requests to the MAPD
* External API (`/api/external/v1/*`) using the HMAC-SHA256 scheme the API
* expects. Copy this file into your own server-side project and use it as-is —
* it has no dependencies beyond Node.js built-in modules and runs on Node >= 18.
*
* ────────────────────────────────────────────────────────────────────────────
* SERVER-SIDE ONLY — NEVER EXPOSE YOUR SECRET IN A BROWSER
* ────────────────────────────────────────────────────────────────────────────
* Your API secret (`mdsk_v1_*`) is a bearer credential. Anyone who obtains it
* can impersonate your integration. Sign requests exclusively in a trusted
* server-side environment. Do NOT bundle this file into browser/front-end code,
* embed the secret in a mobile app, or commit it to source control.
*
* How the signature works (must match the server verifier exactly):
* Each request carries four headers:
* x-md-key Your public key (mdpk_v1_<24 hex chars>)
* x-md-timestamp Unix epoch SECONDS (integer string)
* x-md-nonce Single-use hex nonce (>= 16 lowercase hex chars)
* x-md-signature HMAC-SHA256 hex digest (64 lowercase hex chars)
*
* Signing key = SHA-256(apiSecret) interpreted as raw bytes:
* signingKey = Buffer.from(sha256_hex(apiSecret), 'hex')
*
* Canonical string (UTF-8, fields joined with a single '\n'):
* 1. UPPERCASE HTTP method e.g. "POST"
* 2. path + query, exactly as sent e.g. "/api/external/v1/shows/demo?page=2"
* 3. timestamp the x-md-timestamp value
* 4. nonce the x-md-nonce value
* 5. body hash lowercase hex SHA-256 of the raw body bytes
* (SHA-256 of the empty string when there is no body)
*
* signature = HMAC-SHA256(signingKey, canonicalString) as lowercase hex.
*
* The server rejects requests whose timestamp is more than 5 minutes from its
* clock, and rejects a nonce it has seen before — so generate a fresh nonce
* per request (the default) and keep your clock in sync (e.g. NTP).
*
* @example
* import { createMapdApiClient } from "./mapd-api-client.js"
*
* const client = createMapdApiClient({
* apiKey: process.env.MAPD_API_KEY, // mdpk_v1_... (public key)
* apiSecret: process.env.MAPD_API_SECRET, // mdsk_v1_... (keep server-side!)
* baseUrl: "https://your-mapd-host.example.com",
* })
*
* // GET (no body)
* const res = await client.request("/api/external/v1/shows/my-show/attendees?page=1")
* const attendees = await res.json()
*
* // POST (JSON body) — set content-type yourself; the body is signed verbatim
* const created = await client.request("/api/external/v1/shows/my-show/attendees", {
* method: "POST",
* headers: { "content-type": "application/json" },
* body: JSON.stringify({ firstName: "Ada", lastName: "Lovelace" }),
* })
*
* @module mapd-api-client
*/
import { createHash, createHmac, randomBytes } from "node:crypto"
/** HTTP header carrying the public API key. */
export const HEADER_API_KEY = "x-md-key"
/** HTTP header carrying the Unix epoch-seconds timestamp. */
export const HEADER_TIMESTAMP = "x-md-timestamp"
/** HTTP header carrying the single-use request nonce. */
export const HEADER_NONCE = "x-md-nonce"
/** HTTP header carrying the HMAC-SHA256 signature. */
export const HEADER_SIGNATURE = "x-md-signature"
const DEFAULT_NONCE_LENGTH_BYTES = 16
/**
* @typedef {Object} MapdApiClientConfig
* @property {string} apiKey Public key, `mdpk_v1_<24 hex chars>`.
* @property {string} apiSecret Secret key, `mdsk_v1_*`. SERVER-SIDE ONLY.
* @property {string} baseUrl Absolute base URL of the MAPD host, e.g.
* `https://your-mapd-host.example.com`.
* @property {typeof fetch} [fetchImpl] Custom fetch (defaults to global `fetch`).
* @property {() => string} [nonceFactory] Custom nonce generator (defaults to
* 16 random bytes as hex). Must return a
* fresh lowercase hex string per request.
* @property {() => number} [nowFactory] Custom clock in ms (defaults to `Date.now`).
*/
/** @returns {string} lowercase hex SHA-256 of the given secret. */
function sha256Hex(value) {
return createHash("sha256").update(value, "utf8").digest("hex")
}
/** @returns {string} lowercase hex SHA-256 of the raw request body bytes. */
function hashBodyBytes(bodyBytes) {
return createHash("sha256").update(bodyBytes).digest("hex")
}
/** @returns {string} a fresh single-use nonce (16 random bytes as hex). */
function defaultNonceFactory() {
return randomBytes(DEFAULT_NONCE_LENGTH_BYTES).toString("hex")
}
/**
* Build the canonical string that is signed. Fields are joined with a single
* newline in this exact order: method, path+query, timestamp, nonce, body hash.
*
* @param {string} method HTTP method (case-insensitive; upcased here).
* @param {string} pathWithQuery Path plus query string, exactly as sent.
* @param {string} timestamp Unix epoch-seconds string.
* @param {string} nonce Single-use nonce.
* @param {string} bodyHash Lowercase hex SHA-256 of the raw body bytes.
* @returns {string}
*/
function buildCanonicalString(method, pathWithQuery, timestamp, nonce, bodyHash) {
return [method.toUpperCase(), pathWithQuery, timestamp, nonce, bodyHash].join("\n")
}
function sanitizePath(path) {
return path.startsWith("/") ? path : `/${path}`
}
/**
* Create a signing client bound to a single API key pair and host.
*
* @param {MapdApiClientConfig} config
* @returns {{ request: (path: string, init?: RequestInit) => Promise<Response> }}
*/
export function createMapdApiClient(config) {
if (!config || !config.apiKey || !config.apiSecret || !config.baseUrl) {
throw new Error("createMapdApiClient requires apiKey, apiSecret, and baseUrl")
}
const fetchImpl = config.fetchImpl ?? fetch
const nowFactory = config.nowFactory ?? Date.now
const nonceFactory = config.nonceFactory ?? defaultNonceFactory
const baseUrl = new URL(config.baseUrl)
const apiKey = config.apiKey
// Signing key = the raw bytes of SHA-256(secret), NOT the secret itself.
const signingKey = Buffer.from(sha256Hex(config.apiSecret), "hex")
/**
* Sign and send a request. `path` may be absolute (`/api/...`) or relative;
* it is resolved against `baseUrl`. The body (if any) is signed verbatim, so
* pass a fully-serialized body and set `content-type` yourself.
*
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<Response>}
*/
async function request(path, init) {
const targetUrl = new URL(sanitizePath(path), baseUrl)
const unsignedRequest = new Request(targetUrl, init)
const bodyBytes = Buffer.from(await unsignedRequest.clone().arrayBuffer())
const nonce = nonceFactory()
const timestamp = String(Math.floor(nowFactory() / 1000))
const bodyHash = hashBodyBytes(bodyBytes)
const pathWithQuery = targetUrl.pathname + targetUrl.search
const canonical = buildCanonicalString(
unsignedRequest.method,
pathWithQuery,
timestamp,
nonce,
bodyHash,
)
const signature = createHmac("sha256", signingKey).update(canonical, "utf8").digest("hex")
const headers = new Headers(unsignedRequest.headers)
headers.set(HEADER_API_KEY, apiKey)
headers.set(HEADER_TIMESTAMP, timestamp)
headers.set(HEADER_NONCE, nonce)
headers.set(HEADER_SIGNATURE, signature)
const signedRequest = new Request(unsignedRequest, { headers })
return fetchImpl(signedRequest)
}
return { request }
}
Errors
Errors return a consistent JSON envelope with a stable machine-readable code and a human-readable message:
{ "error": { "code": "NOT_FOUND", "message": "Resource not found" } }422 validation failures additionally carry a details array of field-level errors:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [{ "field": "email", "message": "email must be a valid email address" }]
}
}| Code | Status | Meaning |
|---|---|---|
missing_headers | 401 | One or more required x-md-* headers are absent. |
invalid_key_format | 401 | The x-md-key does not match mdpk_v1_<24 hex>. |
invalid_timestamp_format | 401 | The timestamp is not a positive integer of seconds. |
invalid_nonce_format | 401 | The nonce is not ≥16 lowercase hex characters. |
invalid_signature | 401 | The signature format is wrong or does not match. |
timestamp_skew | 401 | The timestamp is outside the ±5 minute window. |
key_not_found | 401 | No active key matches the presented public key. |
key_inactive | 401 | The key is revoked or expired. |
nonce_reuse | 401 | The nonce has already been used. |
insufficient_scope | 403 | The key lacks the resource/action scope, or is not bound to the show. |
method_not_allowed | 405 | The HTTP method is not supported for the External API. |
BAD_REQUEST | 400 | Malformed path parameter or request body (e.g. non-JSON body). |
VALIDATION_ERROR | 422 | Field-level validation failed; see error.details. |
NOT_FOUND | 404 | The requested resource does not exist under this show. |
FORBIDDEN | 403 | The operation is not permitted for this resource. |
CONFLICT | 409 | The request conflicts with existing state. |
rate_limit_exceeded | 429 | Per-key rate limit hit; honour the retry-after header. |
SERVER_ERROR | 500 | Unexpected server error. |
Rate limits
The default limit is 120 requests per minute per key. When you exceed it the API responds with 429 and a retry-after header giving the number of seconds to wait before retrying.
Scopes & show binding
Each key is granted scopes as resource + action pairs (for example, attendees + write). The HTTP method of a request determines the action it needs:
| Method | Action |
|---|---|
GET | read |
HEAD | read |
POST | write |
PUT | write |
PATCH | write |
DELETE | delete |
A key is also bound to shows. An all-shows key can operate on every show in the organization; a per-show-bound key can only reach the shows it is explicitly bound to. A request that lacks the required scope, or targets a show the key is not bound to, is rejected with insufficient_scope.
Pagination
List endpoints accept page and page_size query parameters (defaulting to page 1 and 50 items per page). Responses include a pagination object with page, pageSize, totalCount, and totalPages.
GET /api/external/v1/shows/{showId}/attendees?page=2&page_size=25Reference & tooling
The full per-endpoint reference lives at /developers/api/reference. For Postman import or client codegen, point your tooling at the raw OpenAPI document:
/api/external/v1/openapi.json