Retour au guide

Cycle de paie par l'API

Le déroulé d'un mois de paie en appels REST, du calendrier aux PDF, avec ce que chaque étape fait au statut de la fiche.

Développeurs9 min de lecture
Table des matières+

Ce guide déroule un mois de paie complet : préparer le mois, créer les fiches, les valider, les envoyer, les corriger si besoin, puis récupérer les PDF. La création des employés et le détail des champs d'une fiche sont dans Paie, dépenses et comptabilité par l'API. Même clé et même base URL que dans Démarrer avec l'API :

BASE=https://<deployment>.convex.site/api/v1
KEY=ba_...
COMPANY=<companyId>

1. Les statuts de la fiche

Toutes ces routes demandent le plan Business et le module paie (modules.payroll dans GET /me). Le champ status d'une fiche pilote le reste du guide :

  • draft : modifiable et supprimable. Elle est recalculée automatiquement si elle porte sur le mois en cours ou un mois suivant, quand les données de l'employé changent.
  • validated : figée, les recalculs automatiques ne la touchent plus. Une fiche y arrive par validate ou par son envoi. Une correction la ramène en draft.
  • declared : immuable, la validation, la correction et la suppression répondent 409 PAYSLIP_DECLARED. Aucune route de l'API ne pose ce statut.

2. Préparer le mois

Ces routes ne demandent que la lecture et n'écrivent rien. Le calendrier donne les jours fériés tombant en semaine de chaque mois, pour le canton indiqué ou, sans workCanton, celui de la société :

curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payroll/calendar?year=2026"

curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payroll/holiday-variants?year=2026&canton=FR"

weekdayHolidaysByMonth a un élément par mois (l'index 0 est janvier) et reste vide quand hasCalendar vaut false. holiday-variants liste les régimes d'un canton, vide si le canton n'en a qu'un ; la société choisit le sien avec publicHolidayVariant dans PATCH /companies/{companyId}. Pour un employé payé à l'heure, l'aperçu donne ce que la fiche paiera :

curl -H "Authorization: Bearer $KEY" \
  "$BASE/companies/$COMPANY/employees/<employeeId>/holiday-preview?year=2026&month=8&canton=FR"

source vaut calendar (avec days, hours et la liste holidays), percentage (avec rate) ou none. Pour un employé au mois, data vaut null. Avant d'embaucher ou de changer un contrat, l'aperçu légal contrôle des conditions qui ne sont pas encore enregistrées :

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026,
        "employee": { "salaryMode": "monthly", "grossMonthlySalary": 3200, "workPercentage": 100,
                      "weeklyHours": 42, "vacationWeeks": 4, "dateOfBirth": "2003-04-12",
                      "thirteenthSalaryEnabled": false, "workCanton": "GE" } }' \
  $BASE/companies/$COMPANY/payroll/statutory-preview

La réponse est une liste d'avertissements (code, level, params), par exemple vacationBelowStatutoryMinimum ou cantonalMinimumWageBelow. Elle n'empêche rien, c'est à vous d'en tenir compte. Enfin, les absences du mois :

curl -H "Authorization: Bearer $KEY" \
  "$BASE/companies/$COMPANY/employees/<employeeId>/absence-lines?year=2026&month=7"

lines propose les lignes à porter sur la fiche, avec label, amount, type et les indicateurs subjectToCharges, subjectToIS et subjectToInsurance. Rien n'est écrit : recopiez celles que vous retenez dans variableItems. Si missingParams vaut true, aucun paramètre d'indemnité n'est chargé, et une liste vide ne veut donc pas dire qu'il n'y a rien à payer. Deux contrôles complètent : GET …/employees/{employeeId}/absence-compliance?year=2026&month=7 et GET …/employees/{employeeId}/avs-gap?year=2026.

3. Créer les fiches du mois

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "payslips": [
        { "employeeId": "<id1>", "year": 2026, "month": 7, "canton": "FR",
          "variableItems": [{ "label": "Prime de résultat", "amount": 500, "type": "bonus" }] },
        { "employeeId": "<id2>", "year": 2026, "month": 7, "canton": "FR", "hoursWorked": 120 }
      ] }' $BASE/companies/$COMPANY/payslips/batch

curl -H "Authorization: Bearer $KEY" "$BASE/companies/$COMPANY/payslips?year=2026&month=7"

Le lot n'est pas atomique : la réponse donne le résultat de chaque ligne (status vaut created ou error, avec un code). Une ligne de variableItems exige label, amount et type (overtime, bonus, commission, custom, absence, deduction). Les fiches démarrent en draft ; relisez-les, avec leurs lignes, par GET …/payslips/{payslipId}.

4. Valider

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{}' $BASE/companies/$COMPANY/payslips/<payslipId>/validate

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "reason": "Heures à revoir" }' $BASE/companies/$COMPANY/payslips/<payslipId>/unvalidate

validate fige la fiche sans l'envoyer. Son corps est obligatoire, même vide, et reason y est facultatif. La réponse donne changed : false si la fiche était déjà validée. unvalidate la ramène en draft, avec un reason obligatoire, inscrit dans l'historique. Aucune des deux ne recalcule la fiche : un brouillon retrouvé n'est recalculé qu'au prochain changement sur l'employé ou la société.

5. Envoyer

curl -X POST -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/payslips/<payslipId>/send

Sans corps, réponse 204. Le PDF part par e-mail à l'adresse de l'employé, dans la langue de sa fiche, et un brouillon passe en validated ; sentStatus et sentDate sont renseignés. Une fiche déjà envoyée peut être renvoyée. À prévoir : 422 EMPLOYEE_EMAIL_MISSING, 503 PDF_UNAVAILABLE et 502 EMAIL_SEND_FAILED, ces deux derniers à réessayer. Sur le sandbox, rien n'est livré mais le statut change de la même façon.

6. Corriger

Commencez par mesurer l'effet d'une correction. preview-update ne demande que la lecture et n'écrit rien :

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "canton": "FR", "variableItems": [{ "label": "Prime de résultat", "amount": 800, "type": "bonus" }] }' \
  $BASE/companies/$COMPANY/payslips/<payslipId>/preview-update

La réponse donne currentNetSalary, newNetSalary et netDelta, ou null si le calcul n'aboutit pas, par exemple sans barème d'impôt à la source. La correction elle-même prend les mêmes champs, plus une raison et le détail des changements :

curl -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "canton": "FR",
        "variableItems": [{ "label": "Prime de résultat", "amount": 800, "type": "bonus" }],
        "reason": "Prime corrigée",
        "changedFields": [{ "field": "Prime de résultat", "previousValue": "500", "newValue": "800" }],
        "createCorrection": true, "correctionLabel": "Correction prime de juin" }' \
  $BASE/companies/$COMPANY/payslips/<payslipId>
  • Réponse 204. canton, reason et changedFields sont obligatoires ; une reason vide répond 422 REASON_REQUIRED. Renvoyez le canton lu sur la fiche.
  • Sans variableItems ou overrides, les valeurs enregistrées restent. Un tableau les remplace, [] les efface. overrides prend des paires { key, value }, où key est la clé d'une ligne de la fiche, par exemple aanp. Les lignes de correction venues d'un mois précédent sont toujours gardées.
  • Une fiche validée repasse en draft, et son PDF et son indicateur d'envoi sont remis à zéro : relancez validate ou send.
  • changedFields n'est pas calculé : c'est votre description du changement, recopiée dans l'historique.

L'historique se lit, du plus récent au plus ancien, avec reason, changedFields et createdAt :

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/payslips/<payslipId>/modifications

Si c'est une absence qui a changé, GET …/employees/{employeeId}/payslips-to-update?startDate=2026-03-01&endDate=2026-03-31 liste les fiches émises dont les lignes d'absence ne correspondent plus (proposedCount à 0 : la ligne doit simplement disparaître). Cette route n'écrit rien, la correction reste à faire.

Pour repartir de zéro, DELETE …/payslips/{payslipId} supprime une fiche, avec ses lignes et son historique. POST …/payslips/bulk-delete prend de 1 à 100 ids et il est atomique : un identifiant inconnu ou d'une autre société répond 404 NOT_FOUND et rien n'est supprimé. La réponse donne affected et skipped, qui compte les identifiants répétés.

7. Dupliquer sur un autre mois

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026, "month": 8 }' $BASE/companies/$COMPANY/payslips/<payslipId>/duplicate

La réponse 200 donne l'id de la copie, un draft du même employé, recalculé pour son mois avec les lignes variables de l'original. PDF, statut d'envoi et validation ne sont pas repris. À prévoir : 409 PAYSLIP_ALREADY_EXISTS si ce mois existe déjà, 422 PAYSLIP_PERIOD_OUTSIDE_EMPLOYMENT hors de la période d'emploi.

8. Récupérer les PDF par lot

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026, "month": 7, "limit": 25 }' $BASE/companies/$COMPANY/payslips/pdf-batch

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "year": 2026, "month": 7, "limit": 25, "cursor": "<nextCursor>" }' \
  $BASE/companies/$COMPANY/payslips/pdf-batch
{
  "data": [
    { "payslipId": "...", "url": "https://...", "error": null },
    { "payslipId": "...", "url": null, "error": "PDF_UNAVAILABLE" }
  ],
  "nextCursor": "..."
}

limit va de 1 à 25 (10 par défaut), et cette route demande le droit d'écriture, car elle génère les fichiers manquants. Répétez l'appel avec la même période et le nextCursor reçu jusqu'à ce qu'il vaille null. Une ligne en échec n'arrête pas le lot : relancez-la seule avec GET …/payslips/{payslipId}/pdf. Les url sont de courte durée (15 minutes), téléchargez-les tout de suite.

9. Suivre l'espace employé

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/employee-portal-links

curl -H "Authorization: Bearer $KEY" $BASE/companies/$COMPANY/employees/<employeeId>/portal-link

La liste est paginée et donne, par employé, employeeId et status. status contient lastSentAt, expiresAt et active, ou vaut null si aucun lien n'a été envoyé. Ces routes ne font que lire, et ne renvoient jamais le lien lui-même : l'envoi se fait dans l'app, comme l'explique Gérer l'espace employé.

10. Reprises, LAA et AVS : ce qui répond aujourd'hui

En production, chaque mois se calcule seul : le cumul annuel du plafond de l'assurance-chômage (AC), les périodes d'assurance accidents (LAA) datées et le choix AVS annuel n'entrent pas dans le calcul des fiches. Les routes correspondantes figurent quand même dans le contrat, et elles répondent :

  • GET …/payslips/{payslipId}/cumulative-status répond { "enabled": false, "unqualifiedMonths": [] }. validate et send ne bloquent donc pas sur un mois précédent manquant.
  • …/employees/{employeeId}/year-openings/{year} lit (null sans reprise), enregistre (PUT, avec throughMonth, acGross et acBase) et supprime une reprise de cumuls. Elle ne change aucune fiche ordinaire : seuls la lisent les paiements rattachés à une année d'acquisition, versés après la sortie de l'employé ou acquis avant l'âge de référence.
  • …/employees/{employeeId}/laa-periods renvoie [] tant qu'aucune période n'est enregistrée, et …/laa-periods/simulate calcule sans rien écrire. …/laa-summary/{year} reste vide dans ce cas.
  • …/employees/{employeeId}/avs-choices/{year} lit et enregistre un choix annuel, sans effet sur le calcul. Il exige un numéro AVS valable (422 PAYROLL_AVS_IDENTITY_REQUIRED) et une année à partir de 2023 (422 PAYROLL_AVS_CHOICE_INVALID). La renonciation à la franchise qui compte aujourd'hui est le champ avsFranchiseWaived de l'employé.

Pour la pagination, les corps de requête et les codes d'erreur, continuez avec Conventions et erreurs. Le détail de chaque champ est dans la Swagger UI de votre déploiement (…/api/docs).