No description
  • Go 99.2%
  • Shell 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mats Stottmeister ff8a216d5e
Some checks failed
CI / test (1.26) (push) Has been cancelled
CI / test (1.27) (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
Merge pull request 'feat: add OmitTaxType for bookkeeping 2.0 sales documents' (#1) from feat/invoice-omit-tax-type into main
Reviewed-on: #1
2026-10-09 18:26:58 +00:00
.forgejo/workflows feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
.githooks feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
accounting feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
basics feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
catalog feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
client feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
crm feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
internal feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
reporting feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
sales feat: add OmitTaxType for bookkeeping 2.0 sales documents 2026-10-09 20:23:15 +02:00
scripts feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
testdata feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
.gitattributes feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
.gitignore feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
booking.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
booking_test.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
CHANGELOG.md feat: add OmitTaxType for bookkeeping 2.0 sales documents 2026-10-09 20:23:15 +02:00
CONTRIBUTING.md feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
doc.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
document.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
document_test.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
enums.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
errors.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
errors_test.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
example_test.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
go.mod feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
LICENSE feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
list_options.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
money.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
money_test.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
NOTICE feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
README.md feat: add OmitTaxType for bookkeeping 2.0 sales documents 2026-10-09 20:23:15 +02:00
ref.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
scalars.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00
scalars_test.go feat: add sevdesk api client 2026-10-02 01:48:43 +02:00

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).

  • List fetches one page.
  • All returns an iter.Seq2[T, error] over every match, fetching pages of Limit (default 100) and stopping with sevdesk.ErrPaginationStalled if the API repeats a page.
  • Count asks the API for the total (countAll=true).
  • Limit must be 1 to 1000 and Offset must not be negative; both are checked before any request.
  • Extra passes 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.GetPDF and Orders.GetPDF send preventSendBy=true unless sales.PDFOptions.MarkAsSent is set. sevdesk still commits an order whose PDF is fetched, so Orders.GetPDF is never retried on 5xx or transport errors.
  • DATEV exports do not enshrine. Exports.StartDatevCSV and StartDatevXML send enshrineDocuments=false, and DatevCSVLegacy sends enshrine=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/http is kept from replaying them on a reused connection. A 429 rate-limit response is retried for every operation, after its Retry-After delay (at most client.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.