Palisade API Guide

Get started using the Palisade API. Looking for endpoint details? See the API reference. Looking for the application docs instead? The Palisade product documentation covers the dashboard, domain setup, and DMARC rollout.

Authentication

The Palisade API uses API keys to authenticate requests. Create and manage keys from the dashboard — open your avatar menu in the top-right corner and choose API Keys — on any plan, or provision one programmatically with headless signup.

Important: your API keys grant broad access to your account, so keep them secure. Do not share your secret keys in public repositories, client-side code, or any other insecure locations.

All API requests must be made over HTTPS. Requests sent over plain HTTP will be rejected. Apart from a small set of public endpoints (such as this documentation and the OpenAPI spec), any request that does not include valid credentials will fail. Dashboard sessions authenticate with a Bearer JWT instead of an API key.

The API key is used as the username in HTTP Basic authentication, with an empty password:

curl https://api.palisade.email/domains \
  -u "$PALISADE_API_KEY:" \
  -H "Palisade-Version: 2025-04-30"

Headless Signup

Create a Palisade account entirely over the API — no browser required. This is designed for AI agents and automation that control an email inbox.

1. Start the signup with an email address. Work and personal addresses are both accepted; disposable domains are refused with a 422 (disposable_email_domain). No captcha is required. The endpoint is rate-limited per IP, so honor the RateLimit-* response headers. Each email address is separately throttled to a small number of codes per hour; exceeding that returns a 429 (signup_email_throttled):

curl -X POST https://api.palisade.email/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "ops@example.com", "organization_name": "Example Corp"}'

{"signup_id": "<uuid>", "expires_in_seconds": 900}

2. A six-digit code is sent to the inbox. Verify it within 15 minutes and within 5 incorrect attempts; past either limit the signup is dead and every further call returns signup_challenge_invalid, so start a new one:

curl -X POST https://api.palisade.email/signup/verify \
  -H "Content-Type: application/json" \
  -d '{"signup_id": "<uuid>", "code": "123456"}'

The response contains your organization and an API key that is shown exactly once — store it securely. The account is a full Palisade organization: the same email can later sign in to the dashboard via password reset.

From there, the typical flow is: POST /domains to add a domain, GET /domains/{id}/dns-records for the exact DNS records to publish at your DNS provider, then POST /domains/{id}/verify until every record reports verified.

MCP Server

Palisade ships a native Model Context Protocol server so AI agents can drive the platform directly. It exposes Palisade's core workflows as tools, scoped to the organization the caller signed in to.

How sign-in works

There is nothing to create first. A client that calls the endpoint without credentials receives a 401 carrying an RFC 9728 challenge that names the protected-resource metadata:

WWW-Authenticate: Bearer resource_metadata="https://api.palisade.email/.well-known/oauth-protected-resource"

That metadata names the authorization server. Every client you configure yourself signs in with the same public OAuth client, ryKtuiPypMeYMoL1Cmhxtz6BYrEYQbLV, which has no secret (PKCE). The browser opens Palisade's sign-in, you pick an organization, and the client holds a token scoped to it and to your role, so a viewer cannot write through MCP. Connections you have authorised are listed under API and MCP at app.palisade.email. Every assistant that signs in with this client shares one connection, shown there with the assistants that have used it, so revoking it signs all of them out, in every organization you belong to. A token one already holds keeps working until it expires.

A client that registers its own OAuth client is refused. Dynamic client registration and client ID metadata documents both produce a client the authorization server cannot attach an organization to, so the endpoint answers 403 and names the client to use instead.

claude.ai custom connectors

In claude.ai open Settings, then Connectors, then Add custom connector:

Skip the option the dialog recommends, Use Anthropic's hosted client metadata, and register one automatically: both are self-registered clients, and Palisade refuses them.

Claude Code

One command, carrying the client ID and a fixed callback port, because the authorization server registers exact redirect URIs:

claude mcp add --transport http --client-id ryKtuiPypMeYMoL1Cmhxtz6BYrEYQbLV --callback-port 8765 palisade https://api.palisade.email/mcp

Codex and other stdio clients

Clients that run stdio servers, Codex, Cursor and Windsurf among them, use the published bridge, which wraps the remote server over mcp-remote and signs in with the same client. Nothing to export:

codex mcp add palisade -- npx -y @palisadeemail/mcp

The same thing as a client config:

{
  "mcpServers": {
    "palisade": {
      "command": "npx",
      "args": ["-y", "@palisadeemail/mcp"]
    }
  }
}

Tools

Every tool operates on the organization you signed in to, except the two live-DNS reads, which take a bare domain name and read public DNS for any domain, in the account or not.

The golden path for onboarding a domain: create_domain → get_dns_records → publish the records at your DNS provider → verify_domain → monitor list_tasks and reports → complete_task once a fix is verified.

ChatGPT

Palisade supports ChatGPT on the main https://api.palisade.email/mcp endpoint, with the same published OAuth client claude.ai uses. Turn on Developer mode under Settings → Plugins, open Plugins from the sidebar and choose Create app, paste the endpoint, choose OAuth, then under Advanced OAuth settings set Registration method to User-Defined OAuth Client and enter the client ID with no secret. The other two registration methods self-register a client and are refused; see above. Palisade is not listed in the OpenAI app directory, and directory availability is separate from ChatGPT support.

Microsoft 365 Copilot

Your organization's Microsoft 365 administrator must install the Palisade agent in your Microsoft 365 tenant and assign your work account access. Administrators can contact Palisade support to obtain the installation package and help with setup. Distribution is managed by your organization; Palisade is not listed in the public Microsoft Agent Store. Access depends on your tenant policies and Copilot licensing.

  1. Open Microsoft 365 Copilot with the work account your administrator assigned, then select the Palisade agent.
  2. Ask List my Palisade domains.
  3. When prompted, sign in to Palisade and choose your Palisade organization.
  4. Confirm that the returned domains belong to that organization.

Your Microsoft 365 tenant determines who can open the agent. Your chosen Palisade organization and role determine which data and actions it can access. The installed agent includes its connection settings; you do not need an API key, OAuth client ID or client secret.

If the agent is missing, ask your Microsoft 365 administrator to verify installation and your account assignment. If sign-in succeeds but domains do not appear, use Chat settings → Agents → Palisade → Sign out, then reopen the agent and sign in again. If the issue continues, contact support with the error and time of the attempt; do not send tokens or secrets.

To disconnect Copilot, open API and MCP → Connected assistants in Palisade and revoke its dedicated connection. Its label is the configured Palisade application name, which may differ from the agent name. This does not revoke the shared Palisade sign-in connection used by other assistants. An access token already issued remains valid until it expires.

Read-only directory endpoint

https://api.palisade.email/mcp/directory is a second, read-only endpoint carrying 14 of the tools listed above — monitoring and diagnosis only, with no mutation and no billing actions. It exists for clients that reach Palisade through a public app directory, where the connection is made before anyone has chosen an organization.

It differs from https://api.palisade.email/mcp in how it decides which organization you are. It still uses the organization on your token when there is one; what it adds is a fallback for when there is not, looking your account up and taking the single organization it finds. That fallback only works when the answer is unambiguous, so an account in no active organization gets a 403, and so does an account in more than one, because the connection cannot safely pick. The main endpoint has no fallback and simply refuses a token with no organization.

For an OAuth token without an organization, the standard directory connection requires all four of domains:read, groups:read, tasks:read and dmarc_reports:read. Palisade also recognizes ChatGPT's own OAuth clients, meaning third-party clients that can only complete sign-in by redirecting to https://chatgpt.com: when one of them receives only offline_access and no API permissions, the directory endpoint derives read access from the user's membership role in their single active organization. This exception is specific to those clients; other organization-less connections without the four read grants receive a 401 naming the required grants in the WWW-Authenticate header:

WWW-Authenticate: Bearer resource_metadata="https://api.palisade.email/.well-known/oauth-protected-resource/mcp/directory", scope="domains:read groups:read tasks:read dmarc_reports:read"

You need a Palisade organization before connecting. Every MCP tool operates on one, so sign in at app.palisade.email (or provision an account with headless signup) before adding the connector. Connecting without an organization returns a 403 telling you where to create one.

Public audit endpoint

https://api.palisade.email/mcp/public is a third endpoint that needs no account, no organization and no credential. It carries only the two tools that read public DNS for any domain: audit_domain, which scores a domain's full SPF/DKIM/DMARC/BIMI/MTA-STS posture out of 100, and validate_spf_include, which costs an SPF include against the 10-lookup limit before you add it. Neither reads anything of Palisade's, so there is no organization to scope them to; a credential sent to this endpoint is ignored rather than checked.

It is the MCP counterpart of the anonymous GET /tools/audit REST endpoint. It is throttled per client address rather than per identity, in a DNS bucket of its own that is sized like the dns scope under Rate Limits but not shared with it; a call over the limit comes back as a tool error whose error.scope is mcp_dns and which carries retry_after_seconds. Any client that speaks Streamable HTTP connects with nothing to configure:

claude mcp add --transport http palisade-audit https://api.palisade.email/mcp/public

Everything else — monitoring, the records to publish, hosted DMARC, reports and tasks — lives on https://api.palisade.email/mcp and needs a Palisade organization.

Errors

Palisade uses standard HTTP response codes to show whether an API request was successful or not.

Errors from the endpoints in the API reference are JSON bodies carrying a lowercase code you can branch on and a message written for a person; some add a param naming the offending field or a details object with more to go on. Each endpoint's reference entry documents the error shapes it can answer. Two errors are answered before any endpoint is reached and so share one shape across the API: a request without valid API-key credentials answers 401 with code: "unauthorized" and a WWW-Authenticate header, and a path that matches no route answers 404 with code: "not_found" and, under details, links to this guide and to the OpenAPI document, so a client that guessed a URL can find the right one:

{
  "code": "not_found",
  "message": "No API route matches this request.",
  "details": {
    "documentation": "https://developer.palisade.email/docs/guide",
    "openapi": "https://api.palisade.email/swagger.json"
  }
}

The 401 carries the same shape. Its message says whether no credential was sent or the key was not accepted, details.documentation points at Authentication, and the WWW-Authenticate header names both schemes the API accepts: Basic for an API key and Bearer for a dashboard session token. A request a browser made with fetch is challenged with Bearer alone, because a Basic challenge would make the browser open its own sign-in dialog over the app.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="palisade", Bearer realm="palisade"

{
  "code": "unauthorized",
  "message": "The API key is not valid, has been rotated, or has been revoked.",
  "details": {
    "documentation": "https://developer.palisade.email/docs/guide#authentication"
  }
}

A client that sends Accept: text/plain or Accept: text/html receives the plain-text status line for either error instead, which is what both answered before this contract existed; everything else, including no Accept header at all, receives the JSON.

Versioning

Each major release in Palisade may include changes that aren't backward-compatible, which means upgrading could require updates to your existing code. The current major version is 2025-04-30. There is currently no fixed release schedule, but we commit to maintaining major releases for at least one year.

API-key clients must explicitly provide the API version on every request, using the Palisade-Version header; any API-key request that does not include a valid version will fail. Dashboard (JWT) sessions default to the latest version when the header is omitted.

Rate Limits

The API applies independent rate-limit buckets: a global bucket on every request, a per-API-key bucket on every request authenticated with an API key, and DNS buckets (including a stricter per-API-key one) on endpoints that trigger outbound DNS resolution. Every response carries RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers; 429 responses add Retry-After. Honor the headers — do not hard-code limits. The full contract is documented in the API reference introduction.

Pagination

All top-level API resources in Palisade support bulk fetching using "list" methods. For example, you can list domains, groups, or tasks. These list methods follow a consistent structure and accept at least two parameters: page and per_page.

Every "list" method returns the current pagination state in the page_info field. This field also includes the total count of resources matching the provided filters.

Expanding Responses

Palisade lets you request extra details in API responses using the expand request parameter. This parameter works on all API requests and only affects the response for that specific request.

Many objects in Palisade return only the ID of related objects. For example, a domain might include a group ID. Using the expand parameter, you can ask Palisade to include the full group object instead of just the ID. Expandable fields are identified in the expand parameter.

Some fields, like sensitive or optional data, aren't included by default. You can request them with the expand parameter as well.

You can only use expand on get or list requests, and you can expand multiple fields at once by including multiple items in the expand array. Keep in mind that expanding deeply in list responses may slow things down.

Webhooks

Rather than polling for changes, you can register an HTTPS endpoint and Palisade will POST an event to it whenever something happens on your account. Manage endpoints under /webhook-endpoints; the event catalogue is available at GET /webhook-events.

Event catalogue

The table below is generated from the same catalogue the emitter validates against and GET /webhook-events serves, so it cannot drift from what Palisade actually sends. Event names are additive: new ones get added, existing ones are never renamed or repurposed. Treat an unrecognised type as something to ignore rather than an error.

EventFires whendata.object
domain.createdA domain was added to the organization.domain
domain.updatedA domain changed. Fires on monitoring state transitions, score changes, and configuration updates.domain
domain.deletedA domain was removed from the organization.domain
domain.monitoring.activatedA domain reached full DMARC monitoring for the first time. Reaching it again after a break is domain.monitoring.recovered.domain
domain.monitoring.failedMonitoring could not be established or was lost. The domain object carries monitoring_failure_reason, which says whether the record is missing, sits on the parent only, lacks the Palisade rua, and so on.domain
domain.monitoring.driftedA domain that was set up no longer resolves the records Palisade expects.domain
domain.monitoring.recoveredA domain returned to full monitoring after being drifted or failed.domain
domain.hosted_record.readyA Palisade-hosted record is live. data.record names which one: dmarc, spf, dkim, bimi, or mta_sts.domain
domain.hosted_record.errorA Palisade-hosted record is in error. data.record names which one. Recovery is picked up by an hourly reconciliation, so the matching ready event can lag by an hour or more.domain
domain.score_changedA domain crossed a score band — critical, bad, good, or great — rather than merely moving a point. The new band is score_band on the domain object; data.previous_attributes carries the old score and band.domain
domain.policy_changedThe DMARC policy at the domain changed, for example none to quarantine to reject. data.previous_attributes carries the old policy.domain
mta_sts.enabledPalisade-hosted MTA-STS was enabled for a domain.mta_sts
mta_sts.updatedAn MTA-STS policy was changed, for example its mode or MX hosts.mta_sts
mta_sts.disabledPalisade-hosted MTA-STS was disabled for a domain.mta_sts
task.openedA remediation task was opened and stayed open through the stabilization window. Not real-time: see the delivery-timing notes in the API guide.task
task.completedA task was resolved.task
task.canceledPalisade determined the task no longer applies and closed it. Distinct from task.dismissed, which is a person overruling it.task
task.dismissedSomeone dismissed the task. Distinct from task.canceled, which is Palisade closing it automatically.task
task.reopenedA task that had been resolved or canceled came back. Subscribe to this alongside task.completed, or a ticket closed on completion will never learn the work returned.task
organization.updatedOrganization details or settings changed.organization
member.addedAn existing Palisade account was added to the organization.member
member.updatedA member’s roles or group access changed.member
member.removedA member was removed from the organization.member
invitation.createdSomeone was invited to the organization.invitation
invitation.revokedA pending invitation was withdrawn. There is deliberately no invitation.accepted event: acceptance happens inside Auth0 and Palisade cannot currently observe it in real time.invitation
group.createdA group was created.group
group.updatedA group was renamed or its settings changed.group
group.deletedA group was deleted.group

TLS-RPT events are not in the catalogue. Reports are ingested by a separate pipeline that has no channel back into the API, so there is nothing for this service to observe; poll GET /domains/{domainId}/tls-reports instead.

Event payloads

Every delivery has the same envelope. data.object is the affected resource in the same shape the REST API returns it. For events about something that no longer exists, it is the resource as it was immediately before removal. The one case that can be thinner is invitation.revoked: an invitation deleted by its raw provider id is never read back, so that payload carries only {id, object}. Deleting by the id GET /invitations returns — which is what the API does — gives the full object.

{
  "id": "evt_9f2c1b7d8e4a5c3b0d6f1a2e3c4b5d6e",
  "type": "domain.updated",
  "api_version": "2025-04-30",
  "created": "2026-08-11T14:02:11.482Z",
  "data": { "object": { "id": "...", "url": "example.com", "monitoring_status": "setup" } }
}

Verifying signatures

Every request carries a Palisade-Signature header of the form t=<unix seconds>,v1=<hex>. The signature is an HMAC-SHA256, keyed with your endpoint's signing secret, over the string <timestamp>.<raw request body>. Verify against the raw body — re-serializing the parsed JSON will change the bytes and the signature will not match.

import crypto from 'node:crypto';

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=').map((s) => s.trim())),
  );

  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
  if (!parts.v1) return false;

  const expected = crypto.createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');

  // timingSafeEqual throws on a length mismatch, and Buffer.from silently drops non-hex
  // characters, so both have to be checked before comparing.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    if not header:
        return False

    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        timestamp = int(parts["t"])
        received = parts["v1"]
    except (ValueError, KeyError):
        return False

    if abs(int(time.time()) - timestamp) > tolerance:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

The signing secret is returned once, when the endpoint is created, and again only if you call POST /webhook-endpoints/{id}/rotate-secret. Rotation takes effect immediately, so deliveries signed with the previous secret will fail verification.

Delivery semantics

Inspect what happened with GET /webhook-endpoints/{id}/deliveries, which returns recent attempts with their response status and error. Delivery records are kept for 30 days and then pruned, so treat the list as a debugging aid rather than an archive — if you need a permanent record of events, store them on receipt. History outlives the endpoint: you can still read it after deleting the endpoint it belonged to, which is usually when you want it.

Timing you should expect

Events reflect committed state, so a domain.updated always describes something already true. Three lags are worth designing around.

Because of the first point, subscribe to task.reopened alongside task.completed. A ticket closed on completion will otherwise never learn the work came back.

domain.score_changed fires when a domain crosses a band rather than on every point. The band it moved into is score_band on the domain object, and the one it left is in data.previous_attributes, so you never have to reimplement the thresholds.