Contrats de travail par l'API
Déposer, lister, télécharger et retirer un CDDU ou un GUSO depuis un script
Ce que l'API fait — et ce qu'elle ne fait pas
Un contrat de travail est une pièce d'emploi (CDDU ou GUSO) déposée par votre organisation au nom d'une personne : un fichier, un intitulé, un type et une période. Il est rattaché au couple organisation + personne, jamais à un concert ni à une salle — une même personne peut travailler pour plusieurs employeurs sans qu'aucun ne voie les contrats des autres.
| Opération | Par l'API | Route |
|---|---|---|
| Créer la personne destinataire | ✅ oui | POST …/people |
| Créer le contrat | ✅ oui | POST …/people/{person_id}/contracts |
| Lire (liste) | ✅ oui | GET …/people/{person_id}/contracts |
| Lire (le fichier) | ✅ oui | GET …/contracts/{id}/download |
| Retirer le contrat | ✅ oui | DELETE …/contracts/{id} |
| Mettre à jour en place | ❌ non | retirer le dépôt, puis en faire un nouveau — deux gestes, deux traces |
| Modifier ou supprimer une personne | ❌ non | écran Administration → Personnes |
Prérequis
| Condition | Comment la vérifier | Sinon |
|---|---|---|
| L'API d'intégration est activée pour l'organisation | un appel authentifié quelconque répond autre chose qu'un 403 | 403 FEATURE_DISABLED — demandez l'activation à l'administrateur de l'organisation. Le jeton, lui, est valide : ne le renouvelez pas |
Un jeton portant people.read | GET /whoami : la clé est dans scopes et dans effective_permissions | 403 INSUFFICIENT_SCOPE sur les trois lectures |
…et people.write pour créer une personne, déposer et retirer | idem | 403 INSUFFICIENT_SCOPE sur les trois écritures |
| Le propriétaire du jeton a ces droits sur toute l'organisation | son rôle n'est pas restreint à une ou deux salles | 403 — voir l'encadré ci-dessous |
Le reste — obtenir un jeton, le présenter, lire les erreurs, les quotas — est commun à toute l'API et décrit sur la page API d'intégration.
Étape 1 — retrouver la personne, ou la créer
Les trois routes de contrats sont ancrées sur un person_id. Cette collection est la table de résolution qui le donne : sans elle, le dépôt serait une route inutilisable.
/api/v1/{org_slug}/peoplepeople.readLes personnes rattachées à votre organisation, triées par nom.
| Champ | Type | Description |
|---|---|---|
id | integer | l'identifiant à reporter dans les routes de contrats |
first_name | string | prénom |
last_name | string | nom |
email | string | adresse — la seule clé de rapprochement fiable avec votre système |
Paramètres : limit (1 à 100, défaut 50) et offset. Le tri est fixe — nom, prénom, puis identifiant — et cette dernière clé est ce qui garantit qu'un parcours page par page ne répète ni n'omet une ligne.
export COMUSFLOW_TOKEN="cfat_…"
curl -sS -H "Authorization: Bearer $COMUSFLOW_TOKEN" \
"https://app.preprod.comusflow.com/api/v1/ma-salle/people?limit=100" | jq{
"data": [
{ "id": 314, "first_name": "Camille", "last_name": "Blanc",
"email": "camille.blanc@example.org" },
{ "id": 271, "first_name": "Sacha", "last_name": "Duval",
"email": "sacha.duval@example.org" }
],
"pagination": { "limit": 100, "offset": 0, "total": 2, "has_more": false }
}En pratique, résolvez sur l'email plutôt que sur le nom, et gardez la correspondance de votre côté : le person_id est stable.
# L'email est la clé de réconciliation la plus sûre : deux personnes peuvent
# être homonymes, elles ne partagent pas une adresse.
PERSON_ID=$(curl -sS -H "Authorization: Bearer $COMUSFLOW_TOKEN" \
"https://app.preprod.comusflow.com/api/v1/ma-salle/people?limit=100" \
| jq -r '.data[] | select(.email == "camille.blanc@example.org") | .id')
echo "person_id = $PERSON_ID"Si la résolution ne rend rien
Un GUSO nomme parfois quelqu'un que ComusFlow ne connaît pas encore. Votre script n'a alors pas à abandonner la ligne : il crée la fiche, et repart avec son person_id.
/api/v1/{org_slug}/peoplepeople.writeCrée une personne dans votre organisation. Écriture : Idempotency-Key obligatoire.
| Champ | Obligatoire | Description |
|---|---|---|
first_name | oui | prénom (1 à 255 caractères) |
last_name | oui | nom (1 à 255 caractères) |
email | non | adresse — un artiste déclaré au GUSO n'en a pas toujours. Quand elle est là, elle est la clé de rapprochement |
# Personne ne répond à cette adresse : on la crée, puis on repart avec son id.
# La clé d'idempotence est DÉRIVÉE de l'identité visée, pas tirée au hasard :
# relancer le script ne peut donc pas créer deux fiches.
KEY=$(printf 'person|%s' "$EMAIL" | sha256sum | cut -d' ' -f1)
PERSON_ID=$(curl -sS -X POST "https://app.preprod.comusflow.com/api/v1/ma-salle/people" \
-H "Authorization: Bearer $COMUSFLOW_TOKEN" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d "{\"first_name\": \"Camille\", \"last_name\": \"Blanc\", \"email\": \"$EMAIL\"}" \
| jq -r .id){ "id": 314, "first_name": "Camille", "last_name": "Blanc",
"email": "camille.blanc@example.org" }La réponse est un 201 portant exactement l'objet que sert GET /people : vous pouvez enchaîner sur le dépôt sans second appel.
Étape 2 — déposer le contrat
/api/v1/{org_slug}/people/{person_id}/contractspeople.writemultipart/form-data — les champs dans le formulaire, la pièce dans file.
Le corps est multipart : ce n'est pas du JSON avec un fichier en base64. Les champs métier voyagent dans le formulaire, la pièce dans file. Tous sont obligatoires.
| Champ | Format | Règle |
|---|---|---|
contract_type | cddu ou guso | énumération fermée : toute autre valeur est refusée |
title | string, 1 à 255 | intitulé lisible ; les retours à la ligne sont normalisés |
period_start | YYYY-MM-DD | début de la période d'emploi |
period_end | YYYY-MM-DD | ne peut pas être antérieure à period_start |
file | binaire | PDF, JPEG, PNG ou WebP — 15 Mo maximum, non vide |
L'en-tête Idempotency-Key est obligatoire
Toute écriture doit en porter un : une chaîne de votre choix (255 caractères maximum), unique à l'intention que vous exprimez. Un UUID v4 fait l'affaire. Sans lui : 400 IDEMPOTENCY_KEY_REQUIRED.
Nous l'imposons au lieu de le proposer parce qu'un client machine rejoue. Un timeout réseau ne vous dit pas si votre dépôt a abouti, et un contrat en double ne produit aucune erreur : il ressemble à une maladresse de saisie et se découvre des mois plus tard, au contrôle. Rendre l'en-tête facultatif n'aurait protégé que les intégrations qui y pensent — or le doublon naît précisément chez celles qui n'y pensent pas.
export COMUSFLOW_TOKEN="cfat_…"
curl -sS -X POST \
"https://app.preprod.comusflow.com/api/v1/ma-salle/people/314/contracts" \
-H "Authorization: Bearer $COMUSFLOW_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-F "contract_type=cddu" \
-F "title=CDDU novembre — régie lumière" \
-F "period_start=2026-11-02" \
-F "period_end=2026-11-08" \
-F "file=@contrat-signe.pdf"HTTP/1.1 201 Created
{
"id": 1204,
"person_id": 314,
"contract_type": "cddu",
"title": "CDDU novembre — régie lumière",
"period_start": "2026-11-02",
"period_end": "2026-11-08",
"created_at": "2026-11-02T09:41:07Z"
}| Situation | Réponse |
|---|---|
| clé neuve | 201 — le dépôt est exécuté |
| même clé, même corps, dépôt terminé | la réponse mémorisée à l'identique, avec l'en-tête Idempotency-Replayed: true — rien n'a été exécuté |
| même clé, même corps, dépôt encore en cours | 409 IDEMPOTENCY_IN_PROGRESS — réessayez dans un instant, avec la même clé |
| même clé, corps différent | 409 IDEMPOTENCY_KEY_REUSED — changez de clé |
| clé rejouée plus de 24 h après | traitée comme neuve : le dépôt est exécuté |
| dépôt en échec (422, 500) | la clé est libérée : rejouez-la telle quelle |
import uuid
import requests
BASE = "https://app.preprod.comusflow.com/api/v1/ma-salle"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# La clé identifie l'INTENTION, pas la tentative : on la calcule une fois,
# hors de la boucle de retry, sinon chaque nouvel essai crée un doublon.
key = str(uuid.uuid4())
with open("contrat-signe.pdf", "rb") as handle:
response = requests.post(
f"{BASE}/people/314/contracts",
headers={**HEADERS, "Idempotency-Key": key},
data={
"contract_type": "cddu",
"title": "CDDU novembre — régie lumière",
"period_start": "2026-11-02",
"period_end": "2026-11-08",
},
files={"file": ("contrat-signe.pdf", handle, "application/pdf")},
timeout=30,
)
response.raise_for_status()
if response.headers.get("Idempotency-Replayed") == "true":
print("déjà déposé — rien n'a été exécuté cette fois-ci")
print(response.json()["id"])Un champ hors bornes rend un 422 qui nomme le champ fautif :
HTTP/1.1 422 Unprocessable Entity
{
"error": "Validation failed",
"code": "INVALID_INPUT",
"request_id": "5f2c…",
"fields": {
"period_end": ["La fin de période ne peut pas être antérieure à son début."]
}
}Étape 3 — lister les contrats d'une personne
/api/v1/{org_slug}/people/{person_id}/contractspeople.readLes contrats que VOTRE organisation a déposés pour cette personne.
| Champ | Type | Description |
|---|---|---|
id | integer | identifiant du contrat |
person_id | integer | la personne concernée |
contract_type | string | cddu ou guso |
title | string | intitulé donné au dépôt |
period_start | date | YYYY-MM-DD |
period_end | date | YYYY-MM-DD |
created_at | date-time | instant du dépôt, ISO 8601 UTC suffixé Z |
curl -sS -H "Authorization: Bearer $COMUSFLOW_TOKEN" \
"https://app.preprod.comusflow.com/api/v1/ma-salle/people/314/contracts?limit=100" | jq{
"data": [
{
"id": 1204,
"person_id": 314,
"contract_type": "cddu",
"title": "CDDU novembre — régie lumière",
"period_start": "2026-11-02",
"period_end": "2026-11-08",
"created_at": "2026-11-02T09:41:07Z"
}
],
"pagination": { "limit": 100, "offset": 0, "total": 1, "has_more": false }
}Étape 4 — télécharger la pièce
/api/v1/{org_slug}/people/{person_id}/contracts/{contract_id}/downloadpeople.readRend le fichier lui-même, en pièce jointe, dans son format d'origine.
La réponse n'est pas du JSON : c'est le fichier, servi avec un Content-Disposition: attachment et son nom d'affichage. Les droits sont vérifiés avant le premier octet — un flux ne peut plus changer son code HTTP une fois commencé.
curl -sS -L -OJ \
-H "Authorization: Bearer $COMUSFLOW_TOKEN" \
"https://app.preprod.comusflow.com/api/v1/ma-salle/people/314/contracts/1204/download"Un contract_id qui n'appartient pas à cette personne, ou qui a été déposé par une autre organisation, rend 404 NOT_FOUND — le même 404 qu'un identifiant inexistant.
Étape 5 — retirer un contrat, ou le remplacer
/api/v1/{org_slug}/people/{person_id}/contracts/{contract_id}people.writeRetire un dépôt de votre organisation. 204, sans corps.
C'est la route à appeler quand vous retraitez un document déjà déposé. Sans elle, un script qui repassait sur le même PDF n'avait que deux conduites, toutes deux mauvaises : ignorer la ligne en silence, ou déposer un doublon qu'aucune erreur ne signale et qui se découvre au contrôle, des mois plus tard.
curl -sS -X DELETE \
"https://app.preprod.comusflow.com/api/v1/ma-salle/people/314/contracts/1204" \
-H "Authorization: Bearer $COMUSFLOW_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-w '%{http_code}\n'
# → 204| Situation | Réponse |
|---|---|
| contrat déposé par votre organisation pour cette personne | 204 No Content, sans corps |
| contrat déposé par un autre employeur | 404 NOT_FOUND — indiscernable d'un identifiant inexistant |
| contrat rattaché à une autre personne | 404 NOT_FOUND |
jeton sans people.write | 403 INSUFFICIENT_SCOPE |
| même clé rejouée après succès | le 204 mémorisé, avec Idempotency-Replayed: true — pas le 404 de « déjà supprimé » |
Remplacer, c'est deux gestes — il n'existe ni PATCH ni PUT sur un contrat :
# Retraitement d'un PDF déjà déposé : on retire, puis on redépose.
# La clé de RETRAIT est dérivée du contrat visé — deux exécutions du script
# ne peuvent donc pas retirer deux contrats différents par mégarde.
DROP_KEY=$(printf 'withdraw|%s' "$CONTRACT_ID" | sha256sum | cut -d' ' -f1)
curl -sS -X DELETE "$BASE/people/$PERSON_ID/contracts/$CONTRACT_ID" \
"${AUTH[@]}" -H "Idempotency-Key: $DROP_KEY" -o /dev/null -w '%{http_code}\n'
curl -sS -X POST "$BASE/people/$PERSON_ID/contracts" \
"${AUTH[@]}" -H "Idempotency-Key: $NEW_KEY" \
-F "contract_type=$TYPE" -F "title=$TITLE" \
-F "period_start=$START" -F "period_end=$END" \
-F "file=@$FILE"Les refus que vous rencontrerez
Toute réponse d'erreur porte la même enveloppe : error, code, request_id, parfois fields. Branchez votre code sur code, jamais sur le message : le message peut être reformulé, le code est figé.
| HTTP | code | Sur cette surface, cela veut dire | Que faire |
|---|---|---|---|
| 400 | IDEMPOTENCY_KEY_REQUIRED | vous avez déposé sans en-tête d'idempotence | poser Idempotency-Key et rejouer |
| 401 | INVALID_CREDENTIALS | jeton absent, inconnu, expiré, révoqué — indistinguables par conception | vérifier l'en-tête Authorization ; sinon, recréer un jeton |
| 403 | INSUFFICIENT_SCOPE | people.read ou people.write manque à l'intersection | details.required_permission nomme la clé ; comparer scopes et effective_permissions dans /whoami |
| 403 | FEATURE_DISABLED | l'API n'est pas activée pour cette organisation | demander l'activation ; ne pas renouveler le jeton, il est valide |
| 404 | NOT_FOUND | personne hors de votre organisation, contrat d'un autre employeur, ou org_slug qui n'est pas celui du jeton | vérifier l'org_slug, puis relister /people |
| 409 | IDEMPOTENCY_KEY_REUSED | cette clé a déjà servi avec un corps différent | changer de clé — ne pas insister : le serveur refuse pour ne pas vous rendre la réponse d'un autre appel |
| 409 | IDEMPOTENCY_IN_PROGRESS | un appel concurrent porte la même clé | réessayer après un court délai, avec la même clé |
| 422 | INVALID_INPUT | champ manquant ou hors bornes, période inversée, fichier vide, trop lourd ou d'un format refusé | fields détaille champ par champ. Ce n'est jamais transitoire : ne pas rejouer tel quel |
| 429 | TOO_MANY_REQUESTS | quota dépassé (120 appels/minute, 3 000/heure, par jeton) | attendre Retry-After secondes |
Le catalogue complet des codes, les quotas et les en-têtes RateLimit-* sont sur la page API d'intégration.
Recette complète
Le script ci-dessous fait les quatre gestes dans l'ordre et peut être relancé autant de fois qu'on veut : les clés d'idempotence sont dérivées du contenu métier plutôt que tirées au hasard, si bien qu'un second passage rend la réponse mémorisée au lieu de créer un doublon. Il traite aussi le cas d'une personne encore inconnue — la créer fait partie de la recette, ce n'est pas une exception à traiter à la main.
#!/usr/bin/env bash
# Dépose un contrat de travail, de bout en bout, sans doublon possible.
set -euo pipefail
: "${COMUSFLOW_TOKEN:?jeton absent}"
BASE="https://app.preprod.comusflow.com/api/v1/ma-salle"
AUTH=(-H "Authorization: Bearer $COMUSFLOW_TOKEN")
EMAIL="camille.blanc@example.org"
TYPE="cddu"
TITLE="CDDU novembre — régie lumière"
START="2026-11-02"
END="2026-11-08"
FILE="contrat-signe.pdf"
# 1. Résoudre la personne sur son email.
PERSON_ID=$(curl -sS "${AUTH[@]}" "$BASE/people?limit=100" \
| jq -r --arg e "$EMAIL" '.data[] | select(.email == $e) | .id')
# 1 bis. Inconnue ? On la crée, avec une clé dérivée de l'identité visée — deux
# exécutions ne peuvent donc pas produire deux fiches.
if [ -z "$PERSON_ID" ]; then
PERSON_KEY=$(printf 'person|%s' "$EMAIL" | sha256sum | cut -d' ' -f1)
PERSON_ID=$(curl -sS -X POST "$BASE/people" "${AUTH[@]}" \
-H "Idempotency-Key: $PERSON_KEY" -H "Content-Type: application/json" \
-d "{\"first_name\": \"Camille\", \"last_name\": \"Blanc\", \"email\": \"$EMAIL\"}" \
| jq -r .id)
fi
[ -n "$PERSON_ID" ] && [ "$PERSON_ID" != "null" ] || { echo "personne irrésolue : $EMAIL" >&2; exit 1; }
# 2. Une clé DÉRIVÉE de l'intention, pas tirée au hasard : relancer ce script
# deux fois de suite ne peut alors pas créer deux contrats.
KEY=$(printf '%s|%s|%s|%s|%s' "$PERSON_ID" "$TYPE" "$TITLE" "$START" "$END" \
| sha256sum | cut -d' ' -f1)
# 3. Déposer.
HTTP=$(curl -sS -o /tmp/contract.json -D /tmp/contract.headers -w '%{http_code}' \
-X POST "$BASE/people/$PERSON_ID/contracts" \
"${AUTH[@]}" -H "Idempotency-Key: $KEY" \
-F "contract_type=$TYPE" -F "title=$TITLE" \
-F "period_start=$START" -F "period_end=$END" \
-F "file=@$FILE")
case "$HTTP" in
201)
if grep -qi '^idempotency-replayed: true' /tmp/contract.headers; then
echo "déjà déposé — aucune écriture cette fois-ci"
else
echo "contrat déposé : $(jq -r .id /tmp/contract.json)"
fi ;;
409) echo "dépôt concurrent en cours, réessayez dans un instant" >&2; exit 75 ;;
*) echo "échec ($HTTP) : $(jq -r '.code // "?"' /tmp/contract.json)" >&2; exit 1 ;;
esacPour relire l'ensemble des contrats d'une personne, parcourez la collection en vous appuyant sur total, qui compte la collection entière avant fenêtrage :
# Parcourir tous les contrats d'une personne, page par page.
OFFSET=0
while : ; do
PAGE=$(curl -sS "${AUTH[@]}" \
"$BASE/people/$PERSON_ID/contracts?limit=100&offset=$OFFSET")
echo "$PAGE" | jq -c '.data[]'
TOTAL=$(echo "$PAGE" | jq -r '.pagination.total')
OFFSET=$((OFFSET + 100))
[ "$OFFSET" -lt "$TOTAL" ] || break
done