Přeskočit na obsah

Cancel přes DELETE vrací 403 — API klíč nemá DELETE roli, použij POST /cancel

import { Aside } from ‘@astrojs/starlight/components’;

Uživatel v interní aplikaci klikne „Zrušit dokument” / „Cancel envelope”. UI hlásí „zrušeno”, v interní DB se stav změní na CANCELLED. Ale příjemce/podepisující u poskytovatele dál vidí dokument jako odeslaný k podpisu a může ho podepsat. Vznikne nekonzistentní stav — interní systém říká „zrušeno”, externí provider říká „aktivní/sent”.

Stížnost klienta: „smlouvu jsem zrušil, ale ten druhý pán mi tam pořád může podepsat — to musí pryč”.

Integrace volala vendor API přes DELETE:

async function cancelEnvelope(envelopeId: string): Promise<boolean> {
const res = await fetch(`${API}/api/envelopes/${envelopeId}`, {
method: "DELETE",
headers: await authHeaders(),
});
if (res.ok || res.status === 404) return true;
console.error(`Cancel failed: ${res.status}`);
return false;
}

Vendor odpověděl 403 Forbidden:

{
"type": "https://tools.ietf.org/html/rfc2616#section-10",
"title": "An error occurred",
"status": 403,
"detail": "Access Denied. The user doesn't have ROLE_ENVELOPE_DELETE."
}

Tj. servisní API klíč měl scope na ENVELOPE_READ + ENVELOPE_WRITE, ale ENVELOPE_DELETE byl vyhrazený jen pro admin uživatele ve vendor portálu (zpravidla pro permanentní smazání včetně audit logu). DELETE se v tomto API rovná „forget envelope from history”, ne „cancel pending signature workflow”.

Co vendor reálně chce pro cancel je submenu _actions ve vlastním response objektu obálky:

"_actions": {
"cancel": { "method": "POST", "uri": "/api/envelopes/{id}/cancel" },
"discard": { "method": "POST", "uri": "/api/envelopes/{id}/discard" }
}

Cancel = „odvolat pendingovou žádost o podpis”; discard = „odpojit obálku od správy bez ohledu na stav”. Obě jsou POST a fungují s běžným write scope.

Dva sekundární problémy, které integraci dělaly horší:

  1. UI vrstva nehlásila chybu zpět uživateli — backend logoval, ale frontendový endpoint changedu status na CANCELLED bez ohledu na vendor response (cancel byl „best-effort fire-and-forget”). Uživatel viděl „zrušeno”, aniž by se to opravdu stalo.
  2. DB záznam zachoval orphan reference — interní contract.digiSignId zůstal vyplněný, takže další pokusy o re-cancel proti živé obálce z UI nešly (UI button se schovává podle digiSignId, ale ten je odpojen od reálného stavu).

1) Volat správný endpoint:

async function cancelEnvelope(envelopeId: string): Promise<boolean> {
const headers = await authHeaders();
const res = await fetch(`${API}/api/envelopes/${envelopeId}/cancel`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
if (res.ok || res.status === 404) return true;
// Fallback: pokud cancel selže (obálka v terminálním stavu), zkus discard
if (res.status === 409 || res.status === 400) {
const discardRes = await fetch(`${API}/api/envelopes/${envelopeId}/discard`, {
method: "POST",
headers,
body: JSON.stringify({}),
});
if (discardRes.ok || discardRes.status === 404) return true;
}
console.error(`Cancel failed: ${res.status}`);
return false;
}

2) UI gating:

Endpoint, který status mění na CANCELLED, MUSÍ vendor call zavolat dřív než vlastní DB update — a když vendor vrátí false, vrátit chybu uživateli (5xx/4xx) a status v DB nezměnit. Jinak vznikne ten samý nesoulad jen jinou cestou:

if (body.action === "cancel" && contract.digiSignId) {
const ok = await cancelEnvelope(contract.digiSignId);
if (!ok) {
return NextResponse.json(
{ error: "Obálku se nepodařilo zrušit u poskytovatele — smlouva proto NEbyla zrušena." },
{ status: 502 }
);
}
}
// teprve teď DB update na CANCELLED

3) Vyčistit orphan referenci:

if (body.action === "cancel" && contract.digiSignId) {
updateData.digiSignId = null;
}

Pokud máte v DB historické záznamy s orphan referencí (vznikly před fixem), je potřeba je dohledat — typicky WHERE status = 'CANCELLED' AND providerEnvelopeId IS NOT NULL — a buď ručně zrušit jednotlivé obálky u providera + vynulovat, nebo jen vynulovat (pokud už víte, že u providera žijí dál a chcete je nechat na ručním zásahu).

Jak to ověřit u vaší integrace

Sekce “Jak to ověřit u vaší integrace”
Terminál
# 1) Získat auth token
TOKEN=$(curl -s -X POST "$API/api/auth-token" \
-H "Content-Type: application/json" \
-d '{"accessKey":"...","secretKey":"..."}' | jq -r .token)
# 2) Vyzkoušet DELETE na testovací obálce
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \
"$API/api/envelopes/$ENV_ID" \
-H "Authorization: Bearer $TOKEN"
# 403 → DELETE není v scope → použij POST /cancel
# 3) GET obálky a koukni do _actions
curl -s "$API/api/envelopes/$ENV_ID" \
-H "Authorization: Bearer $TOKEN" | jq '._actions'
# Zobrazí dostupné akce s metodou a URI

_actions v response objektu je obecný HATEOAS pattern u řady REST API (Spring HATEOAS, JSON-LD/Hydra, Mason). Vždycky je dobré tam koukat — vendor vám přímo říká, co s daným resource v daný moment můžete dělat s vaším token scope.

Co zkontrolovat ve svém kódu

Sekce “Co zkontrolovat ve svém kódu”
Terminál
# Najít všechna místa, kde voláte vendor API přes DELETE:
grep -rn 'method:\s*"DELETE"' src/lib/services/
# Pro každý ověřit:
# 1) Vyžaduje vendor pro DELETE elevated scope?
# 2) Existuje alternativní POST /cancel nebo /discard?
# 3) Co se stane, když DELETE vrátí 403 — proklouzne to do tichého úspěchu?

Stejný pattern se opakuje u většiny providerů, kde existuje rozdíl mezi „pending cancel” a „permanentní smazání”:

  • StripeDELETE /v1/subscriptions/{id} (cancel) vs. POST /v1/subscriptions/{id} s cancel_at_period_end=true (soft cancel)
  • DocuSign / Adobe Sign / DigiSign / SigniPOST /envelopes/{id}/cancel (recall) vs. DELETE (admin only)
  • Slack / Teams — některé delete operace vyžadují user token, ne bot token
  • PayPal — refund vs. void mají různé scopes
  • [[third-party-api-503-non-blocking]] — kdy je v pořádku „nečekat na vendor odpověď”, a kdy je to past.
  • HATEOAS / hypermedia controls — vendor v response přímo říká, co je teď povolené.
  • OWASP A01:2021 — Broken Access Control: pravidlo „testuj autorizaci s reálným klíčem, ne s admin tokenem”.
Přidal aiarchitekt.cz · 2. 6. 2026 2:00
Provozuje aiarchitekt.cz