- Go 99.2%
- Shell 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| .githooks | ||
| accounting | ||
| basics | ||
| catalog | ||
| client | ||
| crm | ||
| internal | ||
| reporting | ||
| sales | ||
| scripts | ||
| testdata | ||
| .gitattributes | ||
| .gitignore | ||
| booking.go | ||
| booking_test.go | ||
| CHANGELOG.md | ||
| CONTRIBUTING.md | ||
| doc.go | ||
| document.go | ||
| document_test.go | ||
| enums.go | ||
| errors.go | ||
| errors_test.go | ||
| example_test.go | ||
| go.mod | ||
| LICENSE | ||
| list_options.go | ||
| money.go | ||
| money_test.go | ||
| NOTICE | ||
| README.md | ||
| ref.go | ||
| scalars.go | ||
| scalars_test.go | ||
mtn_go_sevdesk
A Go client for the sevdesk accounting API, built on the standard library only.
- Every operation of the official OpenAPI spec plus the undocumented endpoints production integrations rely on.
- Models and encodings checked against the live API, because the spec is wrong in many places.
- Bookkeeping systems 1.0 and 2.0 are both first-class.
- Safe defaults: no accidental "sent" marks, no accidental enshrining, no writes repeated after a server or network error.
Install
go get git.mtnmedia.group/mtn-media-group/mtn_go_sevdesk@latest
It needs Go 1.26 or newer and has no third-party dependencies. The module is split into packages by business area; import the client, the root package for the shared types, and the areas you use:
import (
sevdesk "git.mtnmedia.group/mtn-media-group/mtn_go_sevdesk"
"git.mtnmedia.group/mtn-media-group/mtn_go_sevdesk/client"
"git.mtnmedia.group/mtn-media-group/mtn_go_sevdesk/crm"
"git.mtnmedia.group/mtn-media-group/mtn_go_sevdesk/sales"
)
Packages
| Package | Contents | Services on client.Client |
|---|---|---|
sevdesk (module root) |
shared types: ID, Decimal, Time, Ref and its constructors, Nullable, TaxRule, TaxType, Unity, SendType, BookAmount, Booking, Document, ListOptions, APIError and the Is* helpers |
|
client |
New, the options, RetryPolicy, Limiter, Do |
|
basics |
bookkeeping system version, placeholders, users, payment methods, units, countries, categories, accounting types | Basics |
crm |
contacts, addresses, communication ways, accounting contacts, custom fields | Contacts, ContactAddresses, CommunicationWays, AccountingContacts, ContactCustomFields, ContactCustomFieldSettings |
sales |
invoices, orders, credit notes, their positions and discounts, layouts, emails | Invoices, InvoicePositions, Orders, OrderPositions, CreditNotes, CreditNotePositions, Layouts |
accounting |
vouchers, receipt guidance, bank and cash accounts, transactions, private transaction rules | Vouchers, VoucherPositions, ReceiptGuidance, CheckAccounts, CheckAccountTransactions, PrivateTransactionRules |
catalog |
parts, tags and tag relations | Parts, Tags |
reporting |
DATEV and CSV exports, PDF list reports | Exports, Reports |
A type lives in the package of its area, and the area packages never import each other: sales.InvoiceCreate, crm.ContactListOptions, reporting.ExportProgress. Only the shared types are in the root package.
Quickstart
sd := client.New(token,
client.WithUserAgent("my-integration/1.0"),
client.WithLogger(slog.Default()),
)
version, err := sd.Basics.BookkeepingSystemVersion(ctx)
if err != nil {
return err
}
contact, err := sd.Contacts.Get(ctx, 1234)
Options of the client package:
| Option | Effect |
|---|---|
client.WithBaseURL(url) |
API root, default client.DefaultBaseURL (https://my.sevdesk.de/api/v1) |
client.WithHTTPClient(hc) |
your own *http.Client; redirects are still not followed unless it sets CheckRedirect |
client.WithUserAgent(app) |
prefixes the User-Agent, as sevdesk asks integrations to identify themselves |
client.WithRetryPolicy(p), client.WithoutRetries() |
retry tuning, see client.DefaultRetryPolicy |
client.WithRateLimiter(l) |
waits on l before every attempt; *rate.Limiter from golang.org/x/time/rate fits |
client.WithLogger(l) |
log/slog debug records per attempt and warnings for retries and deprecated endpoints |
The client is immutable and safe for concurrent use. Invalid options never panic: their error is returned by every call. Operations are grouped into services (sd.Contacts, sd.Invoices, sd.Vouchers, ...); sd.Do sends a raw request through the same pipeline for anything the services do not cover. Name the client variable something other than client, such as sd, so it does not shadow the package.
Authentication
sevdesk expects the raw API token in the Authorization header, without a Bearer prefix. client.New trims the token and sends it exactly like that. A client with an empty token returns sevdesk.ErrMissingToken from every call before any network I/O. The token never appears in errors, logs, String() or %#v output, and redirects are not followed, so it never travels to another host.
Errors
A non-2xx response is a *sevdesk.APIError:
| Field | Meaning |
|---|---|
StatusCode, Method, Path |
HTTP status and request, without the query string |
Message |
sevdesk's error message |
Code |
sevdesk's error.code, e.g. 151 for an unknown object; 0 when null |
ErrorCode |
sevdesk's error.errorCode |
ExceptionUUID |
quote it when contacting sevdesk support |
Data |
sevdesk's error.data as redacted raw JSON, usually []; MissingInformation() returns its "missing information" list |
Body, RetryAfter |
redacted body (max 4 KiB) and the parsed Retry-After header |
Check errors with the root helpers sevdesk.IsNotFound, IsUnauthorized, IsForbidden, IsConflict, IsValidation (400 and 422), IsRateLimited and IsServerError, or with errors.AsType[*sevdesk.APIError](err). A single-object read with an empty or null result also counts as not found (sevdesk.ErrNotFound). A 2xx body that does not decode is a *sevdesk.DecodeError with the operation ID and a truncated body.
_, err := sd.Contacts.Get(ctx, 1234)
switch {
case sevdesk.IsNotFound(err):
// no such contact
case err != nil:
if apiErr, ok := errors.AsType[*sevdesk.APIError](err); ok {
log.Printf("sevdesk %d (code %d, errorCode %d, exception %s)", apiErr.StatusCode, apiErr.Code, apiErr.ErrorCode, apiErr.ExceptionUUID)
}
return err
}
Lists and pagination
List endpoints take an options struct that embeds sevdesk.ListOptions (Limit, Offset, Embed, OrderBy, Extra).
Listfetches one page.Allreturns aniter.Seq2[T, error]over every match, fetching pages ofLimit(default 100) and stopping withsevdesk.ErrPaginationStalledif the API repeats a page.Countasks the API for the total (countAll=true).Limitmust be 1 to 1000 andOffsetmust not be negative; both are checked before any request.Extrapasses raw query parameters for filters the spec only describes in prose; they are merged last.
for invoice, err := range sd.Invoices.All(ctx, &sales.InvoiceListOptions{Status: sales.InvoiceStatusOpen}) {
if err != nil {
return err
}
fmt.Println(invoice.InvoiceNumber, invoice.SumGross)
}
Types
sevdesk sends numbers as strings, booleans as "0"/"1", and dates in several formats. The response models use lenient types from the root package that accept every observed form.
| Type | Use |
|---|---|
ID |
object IDs; decodes "123", 123, "" and null |
Ref |
{"id", "objectName"} references; constructors such as ContactRef, TaxRuleRef, CountryRef; Raw() holds an embedded object |
Decimal |
exact, string-backed money and rates (MustDecimal("119.00"), GrossFromNet, NetFromGross); the zero value is "unset", MustDecimal("0") is sent |
Time |
decodes every date format sevdesk sends and is sent as unix seconds; Day(2026, time.October, 1) gives a calendar day at noon UTC |
Int, Bool |
lenient integers and booleans in responses |
Nullable[T] |
partial updates: unset is omitted, Set(v) sends v, Null[T]() sends JSON null |
All XUpdate structs use Nullable fields. Create structs use plain values, plus Nullable where zero or false is meaningful, e.g. sales.InvoiceCreate{TimeToPay: sevdesk.Set(0)}.
Bookkeeping systems 1.0 and 2.0
sevdesk runs two bookkeeping systems, and both are fully supported. Check the account with sd.Basics.BookkeepingSystemVersion(ctx), which returns a basics.BookkeepingVersion; the library never switches behavior on its own.
| System 1.0 | System 2.0 (sevdesk-Update 2.0) | |
|---|---|---|
| VAT on documents | TaxType (and TaxSet with TaxTypeCustom) |
TaxRule |
| Voucher position account | AccountingType (Basics.AccountingTypes) |
AccountDatev (ReceiptGuidance) |
Credit note category ACCOUNTING_TYPE |
supported | not supported |
On invoices, orders and credit notes the library sends TaxRule as a reference and also fills TaxType for the rules that have a 1.0 equivalent, as production does, so the same input works on both systems. On a 2.0 account, set OmitTaxType to send TaxRule alone, without relying on sevdesk's transitional mapping of taxType.
sevdesk.TaxRule |
ID | sevdesk name | For | 1.0 TaxType |
|---|---|---|---|---|
TaxRuleTaxable |
1 | Umsatzsteuerpflichtige Umsätze | revenue | default |
TaxRuleExport |
2 | Ausfuhren | revenue | |
TaxRuleIntraCommunitySupply |
3 | Innergemeinschaftliche Lieferungen | revenue | eu |
TaxRuleTaxFree |
4 | Steuerfreie Umsätze §4 UStG | revenue | |
TaxRuleReverseCharge |
5 | Reverse Charge gem. §13b UStG | revenue | |
TaxRuleIntraCommunityAcquisition |
8 | Innergemeinschaftliche Erwerbe | expense | |
TaxRuleDeductibleExpense |
9 | Vorsteuerabziehbare Aufwendungen | expense | |
TaxRuleNonDeductibleExpense |
10 | Nicht vorsteuerabziehbare Aufwendungen | expense | |
TaxRuleSmallBusiness |
11 | Steuer nicht erhoben nach §19 UStG | revenue | ss |
TaxRuleReverseChargeWithDeduction |
12 | Reverse Charge gem. §13b Abs. 2 UStG mit Vorsteuerabzug | expense | |
TaxRuleReverseChargeWithoutDeduction |
13 | Reverse Charge gem. §13b UStG ohne Vorsteuerabzug | expense | |
TaxRuleReverseChargeEU |
14 | Reverse Charge gem. §13b Abs. 1 EU Umsätze | expense | |
TaxRuleNotTaxableDomestic |
17 | Nicht im Inland steuerbare Leistung | revenue | noteu |
TaxRuleOSSGoods |
18 | One Stop Shop (goods) | revenue | |
TaxRuleOSSElectronicService |
19 | One Stop Shop (electronic service) | revenue | |
TaxRuleOSSOtherService |
20 | One Stop Shop (other service) | revenue | |
TaxRuleReverseCharge18b |
21 | Reverse Charge gem. §18b UStG | revenue |
For the OSS rules, set DeliveryAddressCountry on the invoice, order or credit note when the delivery country differs from the billing country.
Safety defaults
- PDF downloads do not mark documents as sent.
Invoices.GetPDF,CreditNotes.GetPDFandOrders.GetPDFsendpreventSendBy=trueunlesssales.PDFOptions.MarkAsSentis set. sevdesk still commits an order whose PDF is fetched, soOrders.GetPDFis never retried on 5xx or transport errors. - DATEV exports do not enshrine.
Exports.StartDatevCSVandStartDatevXMLsendenshrineDocuments=false, andDatevCSVLegacysendsenshrine=false, unless you set them to true. Enshrining is irreversible. - Non-idempotent requests are never repeated after a server or network error. Only idempotent operations (reads, updates, deletes) are retried on 502, 503, 504 and transport errors. Creating, sending, booking, cancelling, resetting and enshrining are never retried on 5xx or transport errors, and
net/httpis kept from replaying them on a reused connection. A 429 rate-limit response is retried for every operation, after itsRetry-Afterdelay (at mostclient.RetryPolicy.MaxRetryAfter) or a backoff when the header is absent;client.WithoutRetries()turns that off too. A 500 is never retried, because sevdesk sometimes answers invalid input with one.
Verified API quirks
Behavior checked against the live API; where it disagrees with the spec, the library follows the live API.
| Quirk | Handling |
|---|---|
GET /Contact returns only organizations unless depth=1; the name filter needs it too |
crm.ContactListOptions.Depth = crm.ContactDepthAll |
A false query filter is ignored, the same as unset; only true filters (isBooked, onlyCredit, main) |
documented on IsBooked and Main; filter locally for the opposite set |
/Invoice and /Voucher list startDate/endDate accept only unix seconds; date strings silently return nothing |
sevdesk.Time filters are sent as unix seconds |
/CheckAccountTransaction startDate/endDate take dates, day precision, inclusive |
sent as 2006-01-02 |
Export sevQuery[filter][startDate|endDate] returned an empty export for date strings |
documented as unreliable; filter the list endpoints instead |
The first name of a person is surename (sic) |
crm.Contact.FirstName; surname is decoded too |
A contact with name set is an organization; turning it into a person needs "name": null |
crm.ContactUpdate.Name = sevdesk.Null[string]() |
PUT /CommunicationWay and POST /Contact/{id}/addAddress|addEmail|addPhone|addMobile|addWeb are form-encoded |
sent as forms |
/SevUser, /PaymentMethod, /Unity, /StaticCountry, /Category, /AccountingType and the Contact add* endpoints are undocumented |
modeled from live responses |
The undocumented DELETE /Invoice/{id} deletes a draft invoice with its positions |
Invoices.Delete |
saveInvoice and saveVoucher need invoicePosSave/voucherPosSave, not the spec's invoicePos/voucherPos |
built by the factory body functions |
saveInvoice accepts native JSON numbers, booleans and numeric reference IDs, although production integrations send strings |
sent as native JSON |
invoicePosDelete takes an array of references, although the spec documents a single object |
DeletePositions and DeleteDiscounts are sent as arrays on all factories (verified live for invoices) |
Once one trailing factory parameter is set, the spec wants the whole tail; an upsert accepts invoicePosDelete, discountSave, discountDelete and takeDefaultAddress in order, with null or false for the unset ones |
built by the factory body functions (verified live for invoices) |
The saveInvoice response echoes quantity as a JSON boolean for positions created in that call, and adds a vatdrop key |
Quantity of invoice, credit note and order positions stays unset there, read it with ListPositions; vatdrop is ignored |
A custom invoiceNumber does not advance the invoice number sequence |
leave sales.InvoiceCreate.InvoiceNumber empty to use the sequence |
saveInvoice ignores isEInvoice: only propertyIsEInvoice creates an e-invoice, and GET /Invoice does not return the flag |
sales.InvoiceCreate.IsEInvoice is sent as propertyIsEInvoice; check an invoice with GetXML |
Rendering an e-invoice without the structured address or the contact's buyerReference fails with a 400 (code 830) that lists the gaps in error.data |
sales.InvoiceCreate.AddressName, AddressStreet, AddressZip, AddressCity; sevdesk.APIError.MissingInformation() |
A position price is gross when the document's showNet is false: 11.90 + 2 × 5.95 at 19% gives net 20.00, tax 3.80, gross 23.80 |
documented on Price; see sevdesk.GrossFromNet |
getPdf with preventSendBy=true returns the PDF and leaves a draft invoice at status 100 |
GetPDF sends it unless sales.PDFOptions.MarkAsSent |
| Single-object GETs return arrays; money, IDs and statuses come as strings | lenient types and single-object decoding |
Coverage
All 154 operations of the vendored spec (testdata/openapi.json) plus 12 undocumented operations have a service method. Tests enforce it: the operation registry must match the spec, every operation must have exactly one wiring case, and each case must call a method that hits exactly that endpoint. All tests run against httptest servers; none touches the live API.
Recipes
The IDs below are placeholders; look up your own with Basics.Users, Basics.PaymentMethods and the list endpoints.
Ensure a person contact
// ensurePerson returns the person first last, creating it or turning a same-named organization into a person.
func ensurePerson(ctx context.Context, sd *client.Client, first, last string) (*crm.Contact, error) {
opts := &crm.ContactListOptions{Depth: crm.ContactDepthAll, Name: last}
for c, err := range sd.Contacts.All(ctx, opts) {
if err != nil {
return nil, err
}
switch {
case strings.EqualFold(c.FirstName, first) && strings.EqualFold(c.LastName, last):
return &c, nil
case strings.EqualFold(c.Name, first+" "+last):
return sd.Contacts.Update(ctx, c.ID, &crm.ContactUpdate{
Name: sevdesk.Null[string](), // clearing the name makes the contact a person
FirstName: sevdesk.Set(first),
LastName: sevdesk.Set(last),
})
}
}
return sd.Contacts.Create(ctx, &crm.ContactCreate{
Category: crm.ContactCategoryCustomer.Ref(),
FirstName: first,
LastName: last,
})
}
Sync the invoice email
sevdesk sends invoices to the email with the key "Rechnungsadresse" (crm.CommunicationWayKeyInvoicing).
func syncInvoiceEmail(ctx context.Context, sd *client.Client, contactID sevdesk.ID, email string) error {
ways, err := sd.CommunicationWays.List(ctx, &crm.CommunicationWayListOptions{
Contact: sevdesk.ContactRef(contactID),
Type: crm.CommunicationWayTypeEmail,
})
if err != nil {
return err
}
for _, way := range ways {
if way.Key.ID != sevdesk.ID(crm.CommunicationWayKeyInvoicing) {
continue
}
if way.Value != email {
_, err = sd.CommunicationWays.Update(ctx, way.ID, &crm.CommunicationWayUpdate{Value: sevdesk.Set(email)})
}
return err
}
_, err = sd.Contacts.AddEmail(ctx, contactID, crm.CommunicationWayKeyInvoicing, email)
return err
}
Create an e-invoice
An e-invoice needs the structured recipient address on the invoice, and the contact needs a buyerReference (crm.ContactUpdate.BuyerReference, e.g. a Leitweg-ID). Without them sevdesk still saves the invoice, but rendering it fails with a 400 (code 830) whose APIError.MissingInformation() lists the gaps, e.g. document_addressZip or buyerReference.
net, rate := sevdesk.MustDecimal("100.00"), sevdesk.MustDecimal("19")
invoice, err := sd.Invoices.Create(ctx, &sales.InvoiceCreate{
Contact: sevdesk.ContactRef(contactID),
ContactPerson: sevdesk.SevUserRef(sevUserID),
Address: "Erika Mustermann\nMusterstraße 1\n01234 Musterstadt",
AddressName: "Erika Mustermann",
AddressStreet: "Musterstraße 1",
AddressZip: "01234",
AddressCity: "Musterstadt",
AddressCountry: sevdesk.CountryRef(countryID), // see Basics.Countries
InvoiceDate: sevdesk.Day(2026, time.October, 1),
InvoiceType: sales.InvoiceTypeInvoice,
Currency: "EUR",
TaxRule: sevdesk.TaxRuleTaxable,
TaxText: "Umsatzsteuer 19%",
PaymentMethod: sevdesk.PaymentMethodRef(paymentMethodID),
IsEInvoice: true, // sent as propertyIsEInvoice
ShowNet: false, // position prices are gross
Positions: []sales.InvoicePosCreate{{
Name: "Webhosting",
Quantity: sevdesk.MustDecimal("1"),
Price: sevdesk.GrossFromNet(net, rate), // 119.00
TaxRate: rate,
Unity: sevdesk.UnityRef(sevdesk.UnityPiece),
}},
})
if err != nil {
return err
}
The invoice is created as a draft. invoice.Positions holds the saved positions.
Send it with the XML attached
_, err = sd.Invoices.SendViaEmail(ctx, invoice.ID, &sales.EmailParams{
To: "erika.mustermann@example.com",
Subject: "Ihre Rechnung " + invoice.InvoiceNumber,
Text: "Guten Tag,<br><br>anbei erhalten Sie Ihre Rechnung.",
SendXML: true, // attach the e-invoice XML instead of the PDF
})
if err != nil {
return err
}
xml, err := sd.Invoices.GetXML(ctx, invoice.ID) // keep a copy of the XRechnung
if err != nil {
return err
}
Sending marks the invoice as sent; it must not change afterwards. SendViaEmail is never retried on 5xx or transport errors; a 429 rate-limit response is retried, after Retry-After or a backoff.
Migrating from github.com/Mats4k/go_sevdesk
This module is a rewrite of github.com/Mats4k/go_sevdesk with a new module path; nothing from the old package is kept. Create one client and pass a context.Context to every call.
| github.com/Mats4k/go_sevdesk | mtn_go_sevdesk |
|---|---|
go_sevdesk.ApiURL |
client.DefaultBaseURL; change it with client.WithBaseURL |
Token field on every config struct |
client.New(token, ...) once |
go_sevdesk.Contacts(token) |
sd.Contacts.List or All, with Depth: crm.ContactDepthAll to include persons |
go_sevdesk.NewContact(Contact{...}) |
sd.Contacts.Create(ctx, &crm.ContactCreate{...}); Surname is FirstName, Familyname is LastName, Category is crm.ContactCategoryCustomer.Ref() |
go_sevdesk.NewAddress(Address{...}) |
sd.Contacts.AddAddress(ctx, contactID, &crm.AddressAdd{...}) |
NewEmail, NewPhone, NewMobile, NewWebsite |
sd.Contacts.AddEmail, AddPhone, AddMobile, AddWebsite(ctx, contactID, key, value) with a typed crm.CommunicationWayKey |
go_sevdesk.Invoices(token) |
sd.Invoices.List or All |
go_sevdesk.NewInvoice(Invoice{...}, []Position{...}) |
sd.Invoices.Create(ctx, &sales.InvoiceCreate{..., Positions: ...}); Subject is Header, PaymentMethodId is PaymentMethod: sevdesk.PaymentMethodRef(id), and the tax rule v0.1 hard-coded is now TaxRule: sevdesk.TaxRuleTaxable |
Position.PriceNet (converted to gross internally) |
sales.InvoicePosCreate{Price: sevdesk.GrossFromNet(net, rate)} with ShowNet: false, or the net price with ShowNet: true |
go_sevdesk.SendInvoiceEmail(InvoiceEmail{...}) |
sd.Invoices.SendViaEmail(ctx, id, &sales.EmailParams{...}); Email is To, SendOnlyXML is SendXML |
| string IDs, prices and dates | sevdesk.ID, sevdesk.Decimal, sevdesk.Time |
Status
v0.x: the API may still change before v1.0. See CHANGELOG.md.
License
GNU AGPL-3.0-only. See LICENSE and NOTICE. MTN Media Group, as copyright holder, reserves the right to use, modify, and distribute this software under alternative license terms, including in proprietary projects.
If you distribute an application that uses this library, or offer it to users over a network, the AGPL-3.0 applies to that application. MTN Media Group's own proprietary projects are covered by the reservation above.
Contributing
See CONTRIBUTING.md. Contributions are dual-licensed: public AGPL-3.0-only plus a perpetual, irrevocable grant to MTN Media Group permitting proprietary use.