API e webhooks do Marcai.

Liga o teu site, o Zapier, o Make ou o teu próprio sistema às marcações do teu negócio: lê serviços e horários livres, cria e cancela marcações, e recebe avisos em tempo real.

Começar

Cada chave pertence a um negócio e só vê os dados dele. Cria-a no painel, em Integrações → Programadores, e escolhe só as permissões de que precisas. A chave completa aparece uma única vez: guarda-a num servidor teu, nunca no código de um site ou de uma app.

A API e os webhooks fazem parte dos planos que os incluem. Entra no painel para criares a tua primeira chave.

ItemDetalhe
Endereço basehttps://api-production-ca96.up.railway.app/api/v1
ChaveCabeçalho x-api-key: mk_live_… ou Authorization: Bearer mk_live_…
FormatoJSON. Campos em snake_case, dinheiro em cêntimos, datas em ISO 8601 (UTC).
OpenAPIopenapi.json
export MARCAI_API_KEY="mk_live_…"

curl "https://api-production-ca96.up.railway.app/api/v1/services" \
  -H "x-api-key: $MARCAI_API_KEY"

Permissões das chaves

Uma chave só faz o que lhe permitires. Uma chave que só lê nunca cria, remarca nem cancela marcações. Chaves antigas, sem permissões, só leem.

PermissãoO que permite
read:servicesLer serviços e equipa
read:availabilityConsultar horários livres
read:bookingsLer marcações
write:bookingsCriar, remarcar e cancelar marcações
read:clientsLer clientes

Limites, paginação e erros

Cada chave pode fazer 60 pedidos por minuto. Todas as respostas trazem o estado do limite.

CabeçalhoSignificado
X-RateLimit-LimitPedidos permitidos por minuto, por chave (60).
X-RateLimit-RemainingPedidos que ainda podes fazer nesta janela.
X-RateLimit-ResetQuando a janela recomeça (segundos desde 1970, UTC).
Retry-AfterSó no erro 429: segundos até poderes tentar de novo.

As listas usam limit (1 a 100) e cursor. Cada página devolve data, has_more e next_cursor: enquanto houver mais, pede a seguinte com esse cursor.

Os erros têm sempre o mesmo formato. Decide pelo code; a message é para pessoas.

{ "error": { "code": "forbidden", "message": "A chave não tem permissão para criar marcações." } }
codeSignificado
validation_errorAlgum campo do pedido é inválido.
invalid_requestO pedido está mal formado.
invalid_rangeO intervalo de datas é inválido ou grande demais.
invalid_cursorO cursor de paginação não é válido.
unauthorizedChave em falta, inválida ou revogada.
forbiddenA chave não tem a permissão (scope) necessária, ou o plano não inclui a API.
bookings_closedO negócio não está a aceitar marcações.
not_foundO recurso não existe neste negócio.
conflictO horário já não está livre, ou o pedido ainda está em curso.
bookings_by_requestEste negócio só aceita marcações por pedido.
rate_limitedPassaste o limite de pedidos; espera o tempo de Retry-After.
internal_errorErro nosso. Tenta de novo daqui a pouco.

Referência da API

Cada chave pertence a um negócio e só vê os dados dele. Cria as chaves no painel, em Programadores, com os scopes de que precisas (uma chave que só lê nunca escreve).

Limites: 60 pedidos por minuto por chave; cada resposta traz X-RateLimit-Limit, X-RateLimit-Remaining e X-RateLimit-Reset.

Erros: { "error": { "code": "...", "message": "..." } }. Decide pelo code; a message é para pessoas.

Paginação: limit (1–100) e cursor. Cada página traz data, has_more e next_cursor.

Valores: dinheiro em cêntimos; datas em ISO 8601 (UTC).

Negócio

GET/me

Negócio da chave

Não precisa de nenhuma permissão especial, só de uma chave válida.

Devolve o negócio a que a chave pertence. Não precisa de nenhum scope.

Exemplo
curl "https://api-production-ca96.up.railway.app/api/v1/me" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
id*string
name*string
slug*string
timezone*string
currency*string
country*string | null

Erros possíveis: 401 · 403 · 429. O formato está em «Erros».

Catálogo

GET/services

Listar serviços

Permissão necessária: read:services

Serviços activos do negócio, por ordem alfabética.

Parâmetros
ParâmetroOndeTipoDescrição
limitqueryintegerItens por página (1–100).
cursorquerystring`next_cursor` da página anterior. Opaco: não o interpretes.
Exemplo
curl "https://api-production-ca96.up.railway.app/api/v1/services" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
data*array<Service>
data[].id*string
data[].name*string
data[].description*string | null
data[].duration_minutes*integer
data[].price*integerPreço em cêntimos.
data[].promo_price*integer | nullPreço promocional em cêntimos, se houver.
data[].currency*stringCódigo ISO 4217.
data[].is_public*booleanVisível na página pública de marcação.
has_more*booleanHá mais itens depois desta página.
next_cursor*string | nullCursor da página seguinte; null na última.

Erros possíveis: 400 · 401 · 403 · 429. O formato está em «Erros».

GET/staff

Listar equipa

Permissão necessária: read:services

Profissionais activos, com os serviços que executam. Usa staff_id em /availability e /bookings.

Parâmetros
ParâmetroOndeTipoDescrição
limitqueryintegerItens por página (1–100).
cursorquerystring`next_cursor` da página anterior. Opaco: não o interpretes.
service_idquerystringSó quem executa este serviço.
Exemplo
curl "https://api-production-ca96.up.railway.app/api/v1/staff" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
data*array<Staff>
data[].id*string
data[].name*string
data[].title*string | null
data[].is_public*boolean
data[].service_ids*array<string>Serviços que esta pessoa executa.
has_more*booleanHá mais itens depois desta página.
next_cursor*string | nullCursor da página seguinte; null na última.

Erros possíveis: 400 · 401 · 403 · 429. O formato está em «Erros».

Disponibilidade

GET/availability

Horários livres

Permissão necessária: read:availability

Horários reais para um serviço, calculados com as mesmas regras da marcação (horário de trabalho, folgas, antecedência, políticas). Sem staff_id, cada horário traz o profissional que o Marcai atribui. Intervalo máximo: 14 dias.

Parâmetros
ParâmetroOndeTipoDescrição
service_id*querystring
staff_idquerystringSem esta indicação, o Marcai escolhe quem fica com cada horário.
from*querystringPrimeiro dia (fuso do negócio).
toquerystringÚltimo dia, inclusive. Por omissão igual a `from`; máximo 14 dias.
location_idquerystring
Exemplo
curl "https://api-production-ca96.up.railway.app/api/v1/availability?service_id=<service_id>&from=<from>" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
data*array<object>
data[].start_at*string (date-time)Início (UTC, ISO 8601).
data[].end_at*string (date-time)
data[].staff_id*string
data[].label*stringHora local legível, no fuso do negócio.

Erros possíveis: 400 · 401 · 403 · 404 · 429. O formato está em «Erros».

Marcações

GET/bookings

Listar marcações

Permissão necessária: read:bookings

Da mais recente para a mais antiga (por início). Filtra por datas, estado, cliente ou profissional.

Parâmetros
ParâmetroOndeTipoDescrição
limitqueryintegerItens por página (1–100).
cursorquerystring`next_cursor` da página anterior. Opaco: não o interpretes.
fromquerystring (date-time)Marcações que começam a partir de (ISO 8601).
toquerystring (date-time)Marcações que começam antes de (ISO 8601).
statusquery"requested" | "draft" | "held" | "pending_payment" | "pending" | "confirmed" | "cancelled" | "reschedule_requested" | "rescheduled" | "checked_in" | "in_progress" | "completed" | "no_show" | "expired" | "rejected" | "cancelled_by_customer" | "cancelled_by_business"
client_idquerystring
staff_idquerystring
Exemplo
curl "https://api-production-ca96.up.railway.app/api/v1/bookings" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
data*array<Booking>
data[].id*string
data[].status*"requested" | "draft" | "held" | "pending_payment" | "pending" | "confirmed" | "cancelled" | "reschedule_requested" | "rescheduled" | "checked_in" | "in_progress" | "completed" | "no_show" | "expired" | "rejected" | "cancelled_by_customer" | "cancelled_by_business"
data[].start_at*string (date-time)
data[].end_at*string (date-time)
data[].duration_minutes*integer
data[].price*integerPreço estimado em cêntimos.
data[].service*object
data[].service.id*string
data[].service.name*string
data[].staff*object
data[].staff.id*string
data[].staff.name*string
data[].client*object
data[].client.id*string
data[].client.name*string
data[].source*stringOrigem da marcação (ex.: `API`, `PUBLIC_SITE`).
data[].notes*string | nullNotas do cliente.
data[].rescheduled_from_id*string | nullMarcação que esta substituiu, quando veio de uma remarcação.
data[].created_at*string (date-time)
has_more*booleanHá mais itens depois desta página.
next_cursor*string | nullCursor da página seguinte; null na última.

Erros possíveis: 400 · 401 · 403 · 429. O formato está em «Erros».

POST/bookings

Criar uma marcação

Permissão necessária: write:bookings

Cria a marcação depois de revalidar a disponibilidade no servidor, com as mesmas regras e limites da marcação normal. Serviços que exigem pagamento antecipado não se marcam por aqui. Envia sempre um Idempotency-Key: repetir o pedido com a mesma chave devolve a resposta original em vez de criar outra marcação.

Parâmetros
ParâmetroOndeTipoDescrição
Idempotency-KeycabeçalhostringValor único por operação (ex.: um UUID). Repetir o pedido com o mesmo valor, nas 24 h seguintes, devolve a resposta original (cabeçalho `Idempotent-Replayed: true`) sem repetir a operação.
Corpo do pedido (JSON)
CampoTipoDescrição
service_id*string
staff_id*string
start_at*string (date-time)Início desejado (ISO 8601); vem de `GET /availability`.
location_idstring
client_idstringCliente já existente. Em alternativa a `client`.
clientobjectDados do cliente; é reconhecido por email/telefone ou criado.
client.name*string
client.emailstring (email)
client.phonestring
notesstring
Exemplo
curl -X POST "https://api-production-ca96.up.railway.app/api/v1/bookings" \
  -H "x-api-key: $MARCAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: <uuid>" \
  -d '{ ... }'
Resposta 201
CampoTipoDescrição
id*string
status*"requested" | "draft" | "held" | "pending_payment" | "pending" | "confirmed" | "cancelled" | "reschedule_requested" | "rescheduled" | "checked_in" | "in_progress" | "completed" | "no_show" | "expired" | "rejected" | "cancelled_by_customer" | "cancelled_by_business"
start_at*string (date-time)
end_at*string (date-time)
duration_minutes*integer
price*integerPreço estimado em cêntimos.
service*object
service.id*string
service.name*string
staff*object
staff.id*string
staff.name*string
client*object
client.id*string
client.name*string
source*stringOrigem da marcação (ex.: `API`, `PUBLIC_SITE`).
notes*string | nullNotas do cliente.
rescheduled_from_id*string | nullMarcação que esta substituiu, quando veio de uma remarcação.
created_at*string (date-time)

Erros possíveis: 400 · 401 · 403 · 404 · 409 · 429. O formato está em «Erros».

GET/bookings/{id}

Ver uma marcação

Permissão necessária: read:bookings

Parâmetros
ParâmetroOndeTipoDescrição
id*caminhostringId da marcação.
Exemplo
curl "https://api-production-ca96.up.railway.app/api/v1/bookings/<id>" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
id*string
status*"requested" | "draft" | "held" | "pending_payment" | "pending" | "confirmed" | "cancelled" | "reschedule_requested" | "rescheduled" | "checked_in" | "in_progress" | "completed" | "no_show" | "expired" | "rejected" | "cancelled_by_customer" | "cancelled_by_business"
start_at*string (date-time)
end_at*string (date-time)
duration_minutes*integer
price*integerPreço estimado em cêntimos.
service*object
service.id*string
service.name*string
staff*object
staff.id*string
staff.name*string
client*object
client.id*string
client.name*string
source*stringOrigem da marcação (ex.: `API`, `PUBLIC_SITE`).
notes*string | nullNotas do cliente.
rescheduled_from_id*string | nullMarcação que esta substituiu, quando veio de uma remarcação.
created_at*string (date-time)

Erros possíveis: 401 · 403 · 404 · 429. O formato está em «Erros».

PATCH/bookings/{id}

Remarcar ou cancelar

Permissão necessária: write:bookings

Com start_at, remarca: o Marcai revalida o horário, a marcação antiga passa a rescheduled e a resposta é a nova marcação (novo id, com rescheduled_from_id). Com status: "cancelled", cancela (cancelar uma marcação já cancelada não faz nada e devolve-a). Aceita Idempotency-Key.

Parâmetros
ParâmetroOndeTipoDescrição
id*caminhostringId da marcação.
Idempotency-KeycabeçalhostringValor único por operação (ex.: um UUID). Repetir o pedido com o mesmo valor, nas 24 h seguintes, devolve a resposta original (cabeçalho `Idempotent-Replayed: true`) sem repetir a operação.
Exemplo
curl -X PATCH "https://api-production-ca96.up.railway.app/api/v1/bookings/<id>" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
id*string
status*"requested" | "draft" | "held" | "pending_payment" | "pending" | "confirmed" | "cancelled" | "reschedule_requested" | "rescheduled" | "checked_in" | "in_progress" | "completed" | "no_show" | "expired" | "rejected" | "cancelled_by_customer" | "cancelled_by_business"
start_at*string (date-time)
end_at*string (date-time)
duration_minutes*integer
price*integerPreço estimado em cêntimos.
service*object
service.id*string
service.name*string
staff*object
staff.id*string
staff.name*string
client*object
client.id*string
client.name*string
source*stringOrigem da marcação (ex.: `API`, `PUBLIC_SITE`).
notes*string | nullNotas do cliente.
rescheduled_from_id*string | nullMarcação que esta substituiu, quando veio de uma remarcação.
created_at*string (date-time)

Erros possíveis: 400 · 401 · 403 · 404 · 409 · 429. O formato está em «Erros».

Clientes

GET/clients

Listar clientes

Permissão necessária: read:clients

Do mais recente para o mais antigo. q procura por nome, email ou telefone.

Parâmetros
ParâmetroOndeTipoDescrição
limitqueryintegerItens por página (1–100).
cursorquerystring`next_cursor` da página anterior. Opaco: não o interpretes.
qquerystringProcura por nome, email ou telefone.
Exemplo
curl "https://api-production-ca96.up.railway.app/api/v1/clients" \
  -H "x-api-key: $MARCAI_API_KEY"
Resposta 200
CampoTipoDescrição
data*array<Client>
data[].id*string
data[].name*string
data[].email*string | null
data[].phone*string | null
data[].status*string
data[].total_bookings*integer
data[].last_visit_at*string (date-time) | null
data[].created_at*string (date-time)
has_more*booleanHá mais itens depois desta página.
next_cursor*string | nullCursor da página seguinte; null na última.

Erros possíveis: 400 · 401 · 403 · 429. O formato está em «Erros».

Webhooks

Em vez de perguntares ao Marcai de tempos a tempos, indica um endereço https:// teu no painel (Integrações → Programadores) e escolhe os eventos. O Marcai faz um POST a cada evento. Cada endereço tem o seu segredo, mostrado uma só vez; podes testar, pausar, gerar um segredo novo e reenviar entregas.

EventoQuando
booking.createdMarcação criada, por qualquer canal.
booking.updatedConfirmada, remarcada, movida na agenda, check-in, atendimento iniciado, falta.
booking.cancelledCancelada (pelo cliente ou pelo negócio), recusada ou expirada.
booking.completedMarcação concluída.
payment.succeededPagamento recebido.
client.createdCliente novo.

O pedido

O corpo é sempre { id, type, created_at, data }. Os ids de data são os mesmos da API.

CabeçalhoValor
Marcai-Signaturet=<segundos unix>,v1=<assinatura em hexadecimal>
Marcai-EventO tipo do evento, por exemplo booking.created.
Marcai-Event-IdIgual ao campo id do corpo. Usa-o para ignorar repetições.
Marcai-AttemptNúmero da tentativa (1 a 10).
{
  "id": "evt_01hx…",
  "type": "booking.created",
  "created_at": "2026-10-11T10:00:00.000Z",
  "data": {
    "id": "…",
    "status": "confirmed",
    "previous_status": null,
    "start_at": "2026-10-12T09:00:00.000Z",
    "end_at": "2026-10-12T09:30:00.000Z",
    "price": 1500,
    "service_id": "…",
    "staff_id": "…",
    "client_id": "…",
    "source": "PUBLIC_SITE",
    "rescheduled_from_id": null,
    "change": "created"
  }
}

Verificar a assinatura

Antes de confiares num aviso, confirma que foi o Marcai que o enviou: v1 = HMAC-SHA256(segredo, "<t>.<corpo em bruto>"), em hexadecimal. Usa o corpo em bruto, antes de qualquer conversão para JSON, compara com tempo constante e recusa avisos com t a mais de 5 minutos de diferença do teu relógio (repetições maliciosas).

import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const SECRET = process.env.MARCAI_WEBHOOK_SECRET; // whsec_…, mostrado uma só vez no painel

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

const app = express();

// Usa o corpo em bruto: se o converteres para JSON antes, a assinatura deixa de bater.
app.post('/marcai', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verify(rawBody, req.header('Marcai-Signature') ?? '', SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(rawBody);
  // Ignora o que já processaste (event.id) e responde logo; trata o resto depois.
  res.sendStatus(200);
});

app.listen(3000);

Depois de gerares um segredo novo, o anterior deixa de assinar: troca-o no teu servidor logo a seguir.

Entrega e novas tentativas

  • Entrega com sucesso = resposta 2xx em menos de 5 segundos. Responde depressa e processa depois.
  • Se falhar, o Marcai volta a tentar até 10 vezes em cerca de 24 horas (30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 6 h e 10 h).
  • O mesmo evento pode chegar mais do que uma vez (tentativas, reenvio manual): ignora os ids que já viste.
  • A ordem não é garantida entre eventos diferentes: usa created_at.
  • Se 5 entregas seguidas esgotarem as tentativas, o endereço é desativado e o dono do negócio recebe um email. Reativa-o no painel e reenvia o que falhou.
  • O endereço tem de ser https:// e público. Redirecionamentos não são seguidos.
  • Os avisos só levam ids, estados e valores — nunca nomes nem contactos. Para o resto, lê a API com o id recebido.

Dúvidas ou algo que falta? Fala connosco.