Cancel přes DELETE vrací 403 — API klíč nemá DELETE roli, použij POST /cancel
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”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č”.
Root cause
Sekce “Root cause”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ší:
- 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.
- DB záznam zachoval orphan reference — interní
contract.digiSignIdzů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).
Fix
Sekce “Fix”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 CANCELLED3) 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”# 1) Získat auth tokenTOKEN=$(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álcecurl -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 _actionscurl -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”# 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í”:
- Stripe —
DELETE /v1/subscriptions/{id}(cancel) vs.POST /v1/subscriptions/{id}scancel_at_period_end=true(soft cancel) - DocuSign / Adobe Sign / DigiSign / Signi —
POST /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
Související
Sekce “Související”- [[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”.