REST-API für Entwickler

Vereinsdaten automatisch abgleichen und weiterverarbeiten – mit n8n, Make, Zapier oder eigenen Programmen.

API-Referenz öffnenOpenAPI 3.1 (JSON)

Überblick

Mit der REST-API v1 verbinden Vereine svscore.de mit eigenen Programmen und Automationsdiensten wie n8n, Make oder Zapier: Mitglieder aus der Vereinsverwaltung abgleichen, neue Ergebnisse auf der Vereinswebseite oder in einer Messenger-Gruppe veröffentlichen, Serien von einer eigenen Auswertung übernehmen oder den Arbeitsdienst am Jahresende abrechnen.

  • Adresse: https://svscore.de/api/v1 (gleiche Daten auch unter https://shootingscore.app/api/v1, dort mit englischen Fehlertexten)
  • Referenz: alle Endpunkte, Felder und Beispiele unter /api/v1/reference, maschinenlesbar als OpenAPI 3.1
  • Paket: im Paket Verein enthalten. Mit einem Veranstaltungspass ist die API nur lesend nutzbar, solange der Pass läuft.
  • Format: JSON (UTF-8), Feldnamen englisch in camelCase, Fehler nach RFC 9457
Die API ist für Server-zu-Server-Aufrufe gedacht. Bauen Sie den Schlüssel nie in Webseiten-JavaScript oder Apps ein – für Ranglisten auf der Vereinswebseite genügt die öffentliche Ergebnisseite.

Schnellstart

  1. In der Verwaltung unter Verwaltung › Anbindungen › API-Schlüssel auf Neuer Schlüssel klicken (nur Administratoren).
  2. Namen vergeben (z. B. „n8n Mitglieder“), eine Vorlage oder einzelne Scopes wählen, Laufzeit festlegen. Bei aktiver Zwei-Faktor-Anmeldung den Code aus der Authenticator-App eingeben.
  3. Den Schlüssel svs_live_… sofort kopieren – er wird nur einmal angezeigt. svscore.de speichert nur einen Hash.
  4. Verbindung testen:
curl -H "Authorization: Bearer svs_live_abcdefghij_…" https://svscore.de/api/v1/me
$h = @{ Authorization = "Bearer $env:SVSCORE_KEY" }
Invoke-RestMethod -Uri https://svscore.de/api/v1/me -Headers $h
import os, requests
r = requests.get("https://svscore.de/api/v1/me", headers={"Authorization": f"Bearer {os.environ['SVSCORE_KEY']}"})
r.raise_for_status()
print(r.json()["club"]["name"], r.json()["key"]["effectiveScopes"])

/me liefert Verein, Schlüssel (Kennung, Scopes, Ablauf), Limits und Paket – ideal als Verbindungstest in n8n, Make oder Zapier.

Schlüssel und Sicherheit

  • Format: svs_live_<Kennung>_<Geheimnis><Prüfsumme>. Die Kennung (10 Zeichen) ist öffentlich und erscheint in Verwaltung und Protokoll; Tippfehler erkennt die Prüfsumme.
  • Senden Sie den Schlüssel immer im Kopf Authorization: Bearer … – nie als URL-Parameter.
  • Laufzeit 30, 90 oder 365 Tage oder unbegrenzt. 14 Tage vor Ablauf erhalten alle Administratoren eine E-Mail.
  • Erneuern legt einen neuen Schlüssel mit gleichen Scopes an; der alte bleibt gültig, bis Sie ihn widerrufen – so stellen Sie ohne Unterbrechung um. Widerrufen wirkt sofort.
  • Ein Schlüssel handelt wie ein Vereins-Administrator, aber nur im Rahmen seiner Scopes. In Änderungen steht „API: &lt;Name des Schlüssels&gt;“.
  • Ein API-Schlüssel öffnet nur /api/v1 – die Verwaltung selbst (Anmeldung per Passwort) erreicht er nie.

Scopes

Ein Scope hat die Form bereich:read bzw. bereich:write; write schließt read desselben Bereichs ein.

ScopeInhalt
people:readPersonen-Grunddaten (Name, Nummern, Mitgliedschaft, Beitragsgruppe, Geburtsjahr), Beitragsgruppen, Mannschaften
people.contact:readzusätzlich Kontaktdaten (E-Mail, Telefon, Anschrift, Geburtsdatum) – jeder Abruf wird protokolliert
people:writePersonen anlegen und ändern (inkl. beider Lese-Scopes)
events:readVeranstaltungen und Disziplinen
results:readSerien, Einzelschüsse, Ranglisten
results:writeSerien anlegen, Ergebnisse und Schüsse eintragen
cash:readKassen und Buchungen (ohne Namen der Kassenkraft)
work-duty:read / work-duty:writeArbeitsdienst-Übersicht und Stunden / Stunden eintragen
range:readAnwesenheit am Stand, Aufsichtsdienste, Schießstände
firearms:readWaffenbuch je Person – besonders geschützt
documents:readDokumente je Person (WBK, Sachkunde …), Dateien nur einzeln – besonders geschützt

Besonders geschützt: firearms:read und documents:read lassen sich nur wählen, wenn die Zwei-Faktor-Anmeldung des anlegenden Admins aktiv ist, und nur mit Code-Bestätigung. Sie sind in keiner Vorlage vorausgewählt, jeder Abruf wird einzeln protokolliert, Dokumentdateien gibt es nur einzeln. Die API zeigt dabei nicht mehr als die Verwaltung einem Admin (Waffenbuch und Dokumente der Personen dieses Vereins).

Endpunkte

MethodePfadScopeZweck
GET/me–Verbindungstest
GET/contribution-groupspeople:readBeitragsgruppen
GET/teamspeople:readMannschaften
GET/peoplepeople:readPersonen (Filter, Paginierung, Abgleich)
GET/people/{id}people:readeine Person (ETag)
POST/peoplepeople:writePerson anlegen
PATCH/people/{id}people:writePerson ändern (Merge Patch)
GET/eventsevents:readVeranstaltungen
GET/events/{id}events:readVeranstaltung mit Disziplinen
GET/events/{id}/disciplinesevents:readDisziplinen
GET/events/{id}/rankingresults:readRangliste
GET/seriesresults:readSerien (Filter, Abgleich)
GET/series/{id}?shots=trueresults:readSerie mit Einzelschüssen
POST/seriesresults:writeSerie anlegen, optional mit Kassenbuchung
PUT/series/{id}/resultresults:writeErgebnis setzen
PUT/series/{id}/shotsresults:writeEinzelschüsse ersetzen (Server rechnet)
GET/cash/registerscash:readKassen
GET/cash/bookingscash:readBuchungen im Zeitraum
GET/work-duty/overviewwork-duty:readSoll/Ist mit Ersatzbeträgen
GET/work-duty/hourswork-duty:readArbeitsstunden
POST/work-duty/hourswork-duty:writeStunden eintragen
GET/attendancerange:readAnwesenheit
GET/range-supervision/shiftsrange:readAufsichtsdienste
GET/rangesrange:readSchießstände
GET/people/{id}/firearmsfirearms:readWaffenbuch einer Person
GET/people/{id}/documentsdocuments:readDokumente einer Person
GET/documents/{id}/filedocuments:readDatei eines Dokuments

Alle Pfade beginnen mit /api/v1. Unbekannte Query-Parameter werden mit 400 validation abgelehnt – so fallen Tippfehler sofort auf.

Begriffe (Glossar)

FeldBedeutung
scoreRinge der Serie (bei Zehntelwertung mit einer Nachkommastelle)
innerTensInnenzehner
teilerTeiler – Abstand des Treffers vom Mittelpunkt (kleiner ist besser), bewusst als Fachbegriff beibehalten
scoringWertung: bestSeries (Deckserie), sum, time, teiler, luckyShot (Glücksscheibe), honoraryTarget (Ehrenscheibe), eagleCombined (Adler kombiniert)
targetGroupZielgruppe: all, male, female, youth
number (Serie)laufende Nummer der Serie je Person und Disziplin
hiddenErgebnisse verdeckt bis zur Proklamation (Königsschießen) – Werte sind dann null
startNumberStartnummer im Verein
federationNumberVerbands-/Schützenpassnummer
honoraryGuestEhrengast (darf auf die Ehrenscheibe schießen)
contributionGroupBeitragsgruppe (Soll an Arbeitsstunden und Standdiensten)
workSessionausgeschriebener Arbeitseinsatz
x, yTrefferlage in 1/100 mm vom Scheibenmittelpunkt (x nach rechts, y nach oben)

Paginierung

Listen antworten mit

{ "data": [ … ], "hasMore": true, "cursor": "aWQ6NDI.k3…", "nextPage": "https://svscore.de/api/v1/people?limit=50&cursor=aWQ6NDI.k3…" }
  • limit 1–200 (Standard 50). Die nächste Seite holen Sie mit nextPage (zusätzlich als Link: <…>; rel="next"), bis hasMore false ist.
  • Der Cursor ist signiert und gilt nur für diese Liste und diesen Schlüssel.

Abgleich: nur Änderungen holen

Für Personen, Serien und Arbeitsstunden gibt es einen Änderungs-Abgleich über changedSince:

  1. Erster Lauf: GET /api/v1/people?changedSince=0 – alle Datensätze, seitenweise.
  2. Aus der letzten Seite syncState speichern (z. B. in den Static Data von n8n).
  3. Nächster Lauf: GET /api/v1/people?changedSince=<syncState> – nur, was sich seitdem geändert hat, inklusive Löschungen:
{ "id": 17, "deleted": true, "replacedBy": 12 }

replacedBy steht bei zusammengeführten Personen (die Daten liegen jetzt bei Person 12). Der Abgleich erfasst jede Änderung – auch aus Verwaltung, App und Massenänderungen.

Für einfache Polling-Trigger (Zapier, Make) gibt es sort=-changed: zuletzt geänderte Datensätze zuerst.

Schreiben

Idempotency-Key

Senden Sie bei POST den Kopf Idempotency-Key – am besten die ID aus dem Quellsystem (z. B. campai-4711). Wiederholt Ihr Workflow die Anfrage (Zeitüberschreitung, Neustart), liefert svscore.de innerhalb von 24 Stunden die gespeicherte Antwort (Idempotent-Replayed: true) statt einen zweiten Datensatz anzulegen. Gleicher Schlüssel mit anderem Inhalt → 422 idempotency_conflict.

Personen anlegen und ändern

curl -X POST https://svscore.de/api/v1/people \
  -H "Authorization: Bearer $SVSCORE_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: campai-4711" \
  -d '{"firstName":"Anna","lastName":"Adler","gender":"female","isMember":true,"membershipNumber":"100",
       "externalId":"campai-4711","contact":{"email":"anna@example.org","birthDate":"1990-05-06"}}'
  • Pflicht sind firstName und lastName; gender ist male (Standard) oder female.
  • externalId ist Ihre ID aus dem Quellsystem – darüber finden Sie die Person wieder (GET /people?externalId=campai-4711).
  • Ist die Mitgliedsnummer oder externalId schon vergeben, kommt 409 conflict mit existingId – dann mit PATCH aktualisieren.
  • PATCH /people/{id} ist ein JSON Merge Patch (RFC 7396): nur gesendete Felder ändern sich, null leert ein Feld.
  • Unbekannte Felder werden abgelehnt (422 validation mit fields).

ETag und If-Match

GET /people/{id}, /series/{id} und /events/{id} liefern einen ETag. Senden Sie ihn bei Änderungen als If-Match mit – hat inzwischen jemand anderes geändert, kommt 412 version_mismatch statt eines stillen Überschreibens. Mit If-None-Match antwortet ein unveränderter Datensatz mit 304.

Serien, Ergebnisse und Kasse

POST /api/v1/series
{ "personId": 12, "disciplineId": 34, "result": { "score": 98.7, "innerTens": 3 }, "cashRegisterId": 1, "paymentMethod": "card" }
  • Nur aktive Disziplinen nicht archivierter Wettkämpfe (interne Veranstaltungen laufen über Meldungen).
  • Mit cashRegisterId bucht svscore.de den Serienkauf in diese Kasse – zum gleichen Preis wie an der Kasse (erste Serie Startpreis, weitere Nachkauf), paymentMethod cash (Standard), card oder transfer. Ohne cashRegisterId keine Buchung.
  • PUT /series/{id}/result setzt score, innerTens, time, teiler (fehlende Felder = leer) mit denselben Prüfungen wie die Verwaltung.
  • PUT /series/{id}/shots ersetzt alle Einzelschüsse; der Server rechnet Ergebnis, Innenzehner und Teiler wie bei einer Auswertemaschine.

Arbeitsstunden

POST /api/v1/work-duty/hours
{ "personId": 12, "date": "2026-10-17", "from": "08:00", "until": "10:30", "activity": "Rasen mähen" }

Standard ist approved (wie die Erfassung in der Verwaltung); mit "status": "requested" landet der Eintrag zur Prüfung bei den Admins.

Fehler

Fehler kommen als application/problem+json (RFC 9457):

{ "type": "https://svscore.de/entwickler#scope-missing", "title": "Scope missing", "status": 403,
  "detail": "Dem API-Schlüssel fehlt der Scope „people:read“.", "code": "scope_missing", "requiredScope": "people:read", "traceId": "00-…" }

Werten Sie code aus (stabil, englisch); detail ist für Menschen und folgt der Domain bzw. Accept-Language: en.

not_authenticated

401 – kein Authorization-Kopf. Antwort mit WWW-Authenticate: Bearer.

key_invalid

401 – Schlüssel unbekannt, Tippfehler oder falsches Format. Viele Fehlversuche von einer Adresse werden vorübergehend gesperrt.

key_expired

401 – Laufzeit abgelaufen. In der Verwaltung erneuern.

key_revoked

401 – Schlüssel wurde widerrufen.

scope_missing

403 – dem Schlüssel fehlt der Scope requiredScope.

plan

403 – im Paket nicht enthalten (Basis) bzw. schreibender Aufruf mit Veranstaltungspass.

club_suspended

403 – Verein gesperrt oder noch nicht freigeschaltet.

not_found

404 – Datensatz gibt es nicht (oder er gehört zu einem anderen Verein). Bei gelöschten Personen mit deleted und ggf. replacedBy.

conflict

409 – z. B. Mitgliedsnummer oder externe ID schon vergeben; existingId nennt den vorhandenen Datensatz.

idempotency_conflict

409 – gleiche Anfrage läuft noch; 422 – Idempotency-Key schon für einen anderen Inhalt verwendet.

version_mismatch

412 – If-Match passt nicht mehr; neu laden und erneut ändern.

validation

400 – ungültige Parameter/JSON; 422 – fachlich ungültig. fields nennt die betroffenen Felder.

too_many_requests

429 – Limit erreicht; nach Retry-After Sekunden erneut versuchen.

internal

500 – unerwarteter Fehler; wir werden automatisch benachrichtigt. Bitte traceId bei Rückfragen angeben.

Limits

GrenzeWert
Anfragen je Schlüssel600 je Minute (davon schreibend 120)
Anfragen je Verein1.200 je Minute
gleichzeitige Anfragen je Schlüssel4

Jede Antwort enthält RateLimit-Policy und RateLimit (verbleibende Anfragen, Sekunden bis zum Zurücksetzen).

Zeiten und Formate

  • Zeitpunkte als ISO 8601 mit Offset in deutscher Zeit, z. B. 2026-07-01T12:00:00+02:00. Eingaben ohne Offset gelten als deutsche Zeit.
  • Datumswerte yyyy-MM-dd, Uhrzeiten HH:mm, Beträge und Ringe als Zahl.
  • null wird immer mitgeliefert. Neue Felder können jederzeit dazukommen – bitte unbekannte Felder ignorieren.

Datenschutz

  • Der Verein ist Verantwortlicher; angebundene Dienste wählt er selbst. Dienste wie Zapier oder Make verarbeiten die abgerufenen Daten (oft in den USA) – dafür braucht der Verein einen eigenen Auftragsverarbeitungsvertrag mit dem Dienst. Selbst gehostetes n8n ist die datensparsamste Lösung.
  • Wählen Sie nur die nötigen Scopes. Ohne people.contact:read liefert die API nur das Geburtsjahr, keine Kontaktdaten (contact: null).
  • Verdeckte Ergebnisse (vor der Proklamation) liefert die API nie.
  • Unter Verwaltung › Anbindungen › API-Protokoll sehen Admins alle schreibenden Aufrufe, das Lesen von Kontaktdaten und jeden Abruf von Waffen- und Dokumentdaten – nur mit Feldnamen, nie mit Inhalten (90 Tage).
  • Keine CORS-Freigabe: Aufrufe aus dem Browser sind nicht vorgesehen.

Rezept: n8n

Zugang: In n8n unter *Credentials* eine Header Auth anlegen – Name Authorization, Wert Bearer svs_live_….

Neue Ergebnisse nach Telegram

Alle 10 Minuten neue bzw. geänderte Serien holen und Ergebnisse posten. Der Stand liegt in den Static Data des Workflows.

{
  "name": "svscore: neue Ergebnisse → Telegram",
  "nodes": [
    { "name": "Alle 10 Minuten", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [0, 0],
      "parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 10 }] } } },
    { "name": "Stand lesen", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [220, 0],
      "parameters": { "jsCode": "const s = $getWorkflowStaticData('global');\nreturn [{ json: { stand: s.stand ?? '0' } }];" } },
    { "name": "Serien holen", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 0],
      "parameters": {
        "url": "https://svscore.de/api/v1/series", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth",
        "sendQuery": true, "queryParameters": { "parameters": [ { "name": "changedSince", "value": "={{ $json.stand }}" }, { "name": "limit", "value": "200" } ] },
        "options": { "pagination": { "pagination": { "paginationMode": "responseContainsNextURL", "nextURL": "={{ $response.body.nextPage }}",
          "paginationCompleteWhen": "other", "completeExpression": "={{ !$response.body.hasMore }}" } } }
      } },
    { "name": "Stand merken + filtern", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [660, 0],
      "parameters": { "jsCode": "const seiten = $input.all().map(i => i.json);\nconst s = $getWorkflowStaticData('global');\ns.stand = seiten[seiten.length - 1].syncState;\nreturn seiten.flatMap(p => p.data).filter(x => !x.deleted && x.recorded && !x.hidden).map(x => ({ json: x }));" } },
    { "name": "Telegram", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [880, 0],
      "parameters": { "chatId": "-100123456789", "text": "=Neues Ergebnis: Serie {{ $json.id }} – {{ $json.score }} Ringe" } }
  ],
  "connections": {
    "Alle 10 Minuten": { "main": [[{ "node": "Stand lesen", "type": "main", "index": 0 }]] },
    "Stand lesen": { "main": [[{ "node": "Serien holen", "type": "main", "index": 0 }]] },
    "Serien holen": { "main": [[{ "node": "Stand merken + filtern", "type": "main", "index": 0 }]] },
    "Stand merken + filtern": { "main": [[{ "node": "Telegram", "type": "main", "index": 0 }]] }
  }
}

Nach dem Import in der Node Serien holen die Header-Auth-Zugangsdaten wählen und die Chat-ID anpassen. Für Namen und Disziplin zusätzlich GET /people/{id} bzw. GET /events/{id} abfragen (mit Cache).

Mitglieder aus der Vereinsverwaltung übernehmen

Für Campai und easyVerein gibt es den fertigen Abgleich unter *Verwaltung › Anbindungen*. Für andere Systeme:

  1. Mitglieder aus dem Quellsystem holen (HTTP Request bzw. eigene Node).
  2. Je Mitglied POST /api/v1/people mit externalId = ID im Quellsystem und Idempotency-Key = dieselbe ID.
  3. Antwort 409 conflict → existingId nehmen und mit PATCH /api/v1/people/{existingId} aktualisieren (in n8n: *Settings › On Error › Continue (using error output)*).

Arbeitsdienst am Jahresende abrechnen

  1. *Schedule Trigger* einmal im Jahr (z. B. 15. Januar).
  2. GET /api/v1/work-duty/overview?year=2026 – je Mitglied missingHours, missingShifts und amount.
  3. Zeilen mit amount > 0 filtern und in Ihrer Buchhaltung bzw. Vereinsverwaltung eine Rechnung oder einen Lastschrifteinzug anlegen.

Rezept: Make

  1. Modul HTTP › Make a request: URL https://svscore.de/api/v1/people, Methode GET, Header Authorization = Bearer svs_live_…, *Parse response* = Ja.
  2. Paginierung: ein Repeater bzw. die Schleife „solange hasMore“ – die nächste Anfrage geht an nextPage aus der vorigen Antwort.
  3. Für Trigger: Abfrage mit sort=-changed&limit=20 regelmäßig ausführen und über einen Data Store (Schlüssel id) nur neue/geänderte Datensätze weitergeben.

Ausschnitt eines Moduls (Blueprint):

{ "module": "http:ActionSendData", "version": 3,
  "mapper": { "url": "https://svscore.de/api/v1/series", "method": "get", "parseResponse": true,
    "headers": [ { "name": "Authorization", "value": "Bearer {{svscoreKey}}" } ],
    "qs": [ { "name": "sort", "value": "-changed" }, { "name": "limit", "value": "20" } ] } }

Rezept: Zapier

  1. Trigger Webhooks by Zapier › Retrieve Poll: URL https://svscore.de/api/v1/series?sort=-changed&limit=50, Header Authorization: Bearer svs_live_…, *Key* = data.
  2. Zapier erkennt neue Einträge an id. Damit auch geänderte Ergebnisse auslösen, in einem *Code by Zapier*-Schritt eine eigene Kennung id + "-" + updatedAt bilden.
  3. Aktion beliebig (Google Sheets, Slack, E-Mail …). Schreibende Aufrufe über Webhooks by Zapier › Custom Request mit Content-Type: application/json und Idempotency-Key.

Webhooks (sofortige Benachrichtigung statt Polling) sind für Version 1.1 geplant.

Versionierung

  • Die Version steht im Pfad (/api/v1). Neue Felder, Endpunkte, Filter und Aufzählungswerte kommen ohne neue Version – Clients ignorieren Unbekanntes.
  • Brechende Änderungen nur in einer neuen Version; v1 bleibt danach mindestens 12 Monate erreichbar (mit Deprecation/Sunset-Kopf).
  • Änderungen stehen in den Neuigkeiten unter „API“.