Ü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 unterhttps://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
- In der Verwaltung unter Verwaltung › Anbindungen › API-Schlüssel auf Neuer Schlüssel klicken (nur Administratoren).
- 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.
-
Den Schlüssel
svs_live_…sofort kopieren – er wird nur einmal angezeigt. svscore.de speichert nur einen Hash. - 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: <Name des Schlüssels>“.
-
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.
| Scope | Inhalt |
|---|---|
people:read | Personen-Grunddaten (Name, Nummern, Mitgliedschaft, Beitragsgruppe, Geburtsjahr), Beitragsgruppen, Mannschaften |
people.contact:read | zusätzlich Kontaktdaten (E-Mail, Telefon, Anschrift, Geburtsdatum) – jeder Abruf wird protokolliert |
people:write | Personen anlegen und ändern (inkl. beider Lese-Scopes) |
events:read | Veranstaltungen und Disziplinen |
results:read | Serien, Einzelschüsse, Ranglisten |
results:write | Serien anlegen, Ergebnisse und Schüsse eintragen |
cash:read | Kassen und Buchungen (ohne Namen der Kassenkraft) |
work-duty:read / work-duty:write | Arbeitsdienst-Übersicht und Stunden / Stunden eintragen |
range:read | Anwesenheit am Stand, Aufsichtsdienste, Schießstände |
firearms:read | Waffenbuch je Person – besonders geschützt |
documents:read | Dokumente 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
| Methode | Pfad | Scope | Zweck |
|---|---|---|---|
| GET | /me | – | Verbindungstest |
| GET | /contribution-groups | people:read | Beitragsgruppen |
| GET | /teams | people:read | Mannschaften |
| GET | /people | people:read | Personen (Filter, Paginierung, Abgleich) |
| GET | /people/{id} | people:read | eine Person (ETag) |
| POST | /people | people:write | Person anlegen |
| PATCH | /people/{id} | people:write | Person ändern (Merge Patch) |
| GET | /events | events:read | Veranstaltungen |
| GET | /events/{id} | events:read | Veranstaltung mit Disziplinen |
| GET | /events/{id}/disciplines | events:read | Disziplinen |
| GET | /events/{id}/ranking | results:read | Rangliste |
| GET | /series | results:read | Serien (Filter, Abgleich) |
| GET | /series/{id}?shots=true | results:read | Serie mit Einzelschüssen |
| POST | /series | results:write | Serie anlegen, optional mit Kassenbuchung |
| PUT | /series/{id}/result | results:write | Ergebnis setzen |
| PUT | /series/{id}/shots | results:write | Einzelschüsse ersetzen (Server rechnet) |
| GET | /cash/registers | cash:read | Kassen |
| GET | /cash/bookings | cash:read | Buchungen im Zeitraum |
| GET | /work-duty/overview | work-duty:read | Soll/Ist mit Ersatzbeträgen |
| GET | /work-duty/hours | work-duty:read | Arbeitsstunden |
| POST | /work-duty/hours | work-duty:write | Stunden eintragen |
| GET | /attendance | range:read | Anwesenheit |
| GET | /range-supervision/shifts | range:read | Aufsichtsdienste |
| GET | /ranges | range:read | Schießstände |
| GET | /people/{id}/firearms | firearms:read | Waffenbuch einer Person |
| GET | /people/{id}/documents | documents:read | Dokumente einer Person |
| GET | /documents/{id}/file | documents:read | Datei eines Dokuments |
Alle Pfade beginnen mit /api/v1. Unbekannte Query-Parameter werden mit 400 validation abgelehnt – so fallen Tippfehler sofort auf.
Begriffe (Glossar)
| Feld | Bedeutung |
|---|---|
score | Ringe der Serie (bei Zehntelwertung mit einer Nachkommastelle) |
innerTens | Innenzehner |
teiler | Teiler – Abstand des Treffers vom Mittelpunkt (kleiner ist besser), bewusst als Fachbegriff beibehalten |
scoring | Wertung: bestSeries (Deckserie), sum, time, teiler, luckyShot (Glücksscheibe), honoraryTarget (Ehrenscheibe), eagleCombined (Adler kombiniert) |
targetGroup | Zielgruppe: all, male, female, youth |
number (Serie) | laufende Nummer der Serie je Person und Disziplin |
hidden | Ergebnisse verdeckt bis zur Proklamation (Königsschießen) – Werte sind dann null |
startNumber | Startnummer im Verein |
federationNumber | Verbands-/Schützenpassnummer |
honoraryGuest | Ehrengast (darf auf die Ehrenscheibe schießen) |
contributionGroup | Beitragsgruppe (Soll an Arbeitsstunden und Standdiensten) |
workSession | ausgeschriebener Arbeitseinsatz |
x, y | Trefferlage 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…" }
-
limit1–200 (Standard 50). Die nächste Seite holen Sie mitnextPage(zusätzlich alsLink: <…>; rel="next"), bishasMorefalseist. - 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:
-
Erster Lauf:
GET /api/v1/people?changedSince=0– alle Datensätze, seitenweise. -
Aus der letzten Seite
syncStatespeichern (z. B. in den Static Data von n8n). -
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
firstNameundlastName;genderistmale(Standard) oderfemale. -
externalIdist Ihre ID aus dem Quellsystem – darüber finden Sie die Person wieder (GET /people?externalId=campai-4711). -
Ist die Mitgliedsnummer oder
externalIdschon vergeben, kommt409 conflictmitexistingId– dann mitPATCHaktualisieren. -
PATCH /people/{id}ist ein JSON Merge Patch (RFC 7396): nur gesendete Felder ändern sich,nullleert ein Feld. -
Unbekannte Felder werden abgelehnt (
422 validationmitfields).
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
cashRegisterIdbucht svscore.de den Serienkauf in diese Kasse – zum gleichen Preis wie an der Kasse (erste Serie Startpreis, weitere Nachkauf),paymentMethodcash(Standard),cardodertransfer. OhnecashRegisterIdkeine Buchung. -
PUT /series/{id}/resultsetztscore,innerTens,time,teiler(fehlende Felder = leer) mit denselben Prüfungen wie die Verwaltung. -
PUT /series/{id}/shotsersetzt 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
| Grenze | Wert |
|---|---|
| Anfragen je Schlüssel | 600 je Minute (davon schreibend 120) |
| Anfragen je Verein | 1.200 je Minute |
| gleichzeitige Anfragen je Schlüssel | 4 |
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, UhrzeitenHH:mm, Beträge und Ringe als Zahl. -
nullwird 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:readliefert 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:
- Mitglieder aus dem Quellsystem holen (HTTP Request bzw. eigene Node).
-
Je Mitglied
POST /api/v1/peoplemitexternalId= ID im Quellsystem undIdempotency-Key= dieselbe ID. -
Antwort
409 conflict→existingIdnehmen und mitPATCH /api/v1/people/{existingId}aktualisieren (in n8n: *Settings › On Error › Continue (using error output)*).
Arbeitsdienst am Jahresende abrechnen
- *Schedule Trigger* einmal im Jahr (z. B. 15. Januar).
-
GET /api/v1/work-duty/overview?year=2026– je MitgliedmissingHours,missingShiftsundamount. -
Zeilen mit
amount > 0filtern und in Ihrer Buchhaltung bzw. Vereinsverwaltung eine Rechnung oder einen Lastschrifteinzug anlegen.
Rezept: Make
-
Modul HTTP › Make a request: URL
https://svscore.de/api/v1/people, Methode GET, HeaderAuthorization=Bearer svs_live_…, *Parse response* = Ja. -
Paginierung: ein Repeater bzw. die Schleife „solange
hasMore“ – die nächste Anfrage geht annextPageaus der vorigen Antwort. -
Für Trigger: Abfrage mit
sort=-changed&limit=20regelmäßig ausführen und über einen Data Store (Schlüsselid) 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
-
Trigger Webhooks by Zapier › Retrieve Poll: URL
https://svscore.de/api/v1/series?sort=-changed&limit=50, HeaderAuthorization: Bearer svs_live_…, *Key* =data. -
Zapier erkennt neue Einträge an
id. Damit auch geänderte Ergebnisse auslösen, in einem *Code by Zapier*-Schritt eine eigene Kennungid + "-" + updatedAtbilden. -
Aktion beliebig (Google Sheets, Slack, E-Mail …). Schreibende Aufrufe über Webhooks by Zapier › Custom Request mit
Content-Type: application/jsonundIdempotency-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“.