Welcome | Get started | Dive | Contribute | Topics | Reference | Changes | More
Exploring the Ibanity API¶
This section explores the raw Ibanity API, which is used internally by this plugin. As a Lino applciation developer you won’t need to know these details if you just use the plugin.
All code snippets on this page (lines starting with >>>
) are being
tested as part of our development workflow. The following
snippet initializes a demo project to use throughout this page.
>>> from lino_book.projects.noi1r.startup import *
The tests in this document are skipped unless you have Ibanity credentials installed. See How to set up your credentials for details.
>>> if not dd.plugins.peppol.credentials:
... pytest.skip('this doctest requires Ibanity credentials')
Access token¶
The peppol.credentials
setting is the identifier of your
application in the Ibanity developer portal.
This is a string of the form “{client_id}:{client_secret}”.
>>> dd.plugins.peppol.credentials
'a38f6dd1-7b48-4dbc-8493-2a142b6e134a:valid_client_secret'
>>> dd.plugins.peppol.cert_file
PosixPath('.../secrets/certificate.pem')
>>> print(dd.plugins.peppol.cert_file.read_text().strip())
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
>>> ses = dd.plugins.peppol.get_ibanity_session()
>>> pprint(ses.get_access_token())
{'access_token': '...',
'expires_in': 300,
'not-before-policy': 0,
'refresh_expires_in': 300,
'refresh_token': '...',
'scope': 'email profile',
'session_state': '94bdf4ce-0dea-4ab7-abda-a53c7ba4bc87',
'token_type': 'bearer'}
The above code snippet is a confirmation that your credentials are set up correctly. Congratulations! You won’t need this access token directly, but Lino will call it internally for every API call.
Suppliers¶
A supplier in Ibanity is the one sending and/or receiving an invoice via Peppol. Those are the customers of the hosting provider. Internally the Ibanity team uses the term “end user” instead of “supplier” and might change that at some point on the API too. An end user is the one using your software.
>>> list_suppliers = ses.list_suppliers()
>>> pprint(list_suppliers)
{'data': [{'attributes': {'city': 'Leuven',
'companyNumber': '1234567890',
'contactEmail': 'contact@example.be',
'country': 'Belgium',
'createdAt': '...',
'email': 'someone@example.be',
'enterpriseIdentification': {'enterpriseNumber': '1234567890',
'vatNumber': 'BE1234567890'},
'homepage': 'www.home.com',
'ibans': [{'id': 'bdfa52c6-2b50-4690-8b8d-24541a92c578',
'value': 'BE68539007547034'},
{'id': 'dcb9f7c2-be2c-4b52-8d77-3ed2bc05c5f8',
'value': 'BE68539007547034'}],
'names': [{'id': '3dffba33-97af-4477-9fab-2d9d2dc31cee',
'value': 'Company'},
{'id': '99e410cc-d6f0-4f36-8096-949741ea8ec3',
'value': 'Company S.A.'}],
'onboardingStatus': 'CREATED',
'peppolReceiver': False,
'phoneNumber': '+3254321121',
'street': 'Street',
'streetNumber': '2',
'supportEmail': 'support@example.be',
'supportPhone': '+3212345121',
'supportUrl': 'www.support.com',
'zip': '3000'},
'id': '273c1bdf-6258-4484-b6fb-74363721d51f',
'type': 'supplier'}],
'meta': {'paging': {'number': 0, 'size': 2000, 'total': 1}}}
>>> for sup in list_suppliers['data']:
... createdAt1 = sup['attributes'].pop('createdAt')
... vat_id = sup['attributes']['enterpriseIdentification']['vatNumber']
... sup_info = ses.get_supplier(sup['id'])
... createdAt2 = sup_info['data']['attributes'].pop('createdAt')
... assert sup_info['data']['attributes'] == sup['attributes']
... print(f"Supplier {sup['id']} has VAT id {vat_id}")
Supplier 273c1bdf-6258-4484-b6fb-74363721d51f has VAT id BE1234567890
>>> from lino_xl.lib.peppol.utils import DEMO_SUPPLIER_ID
>>> DEMO_SUPPLIER_ID
'273c1bdf-6258-4484-b6fb-74363721d51f'
>>> pprint(ses.get_supplier(DEMO_SUPPLIER_ID))
{'data': {'attributes': {'city': 'Leuven',
'companyNumber': '1234567890',
'contactEmail': 'contact@example.be',
'country': 'Belgium',
'createdAt': '...',
'email': 'someone@example.be',
'enterpriseIdentification': {'enterpriseNumber': '1234567890',
'vatNumber': 'BE1234567890'},
'homepage': 'www.home.com',
'ibans': [{'id': 'bdfa52c6-2b50-4690-8b8d-24541a92c578',
'value': 'BE68539007547034'},
{'id': 'dcb9f7c2-be2c-4b52-8d77-3ed2bc05c5f8',
'value': 'BE68539007547034'}],
'names': [{'id': '3dffba33-97af-4477-9fab-2d9d2dc31cee',
'value': 'Company'},
{'id': '99e410cc-d6f0-4f36-8096-949741ea8ec3',
'value': 'Company S.A.'}],
'onboardingStatus': 'CREATED',
'peppolReceiver': False,
'phoneNumber': '+3254321121',
'street': 'Street',
'streetNumber': '2',
'supportEmail': 'support@example.be',
'supportPhone': '+3212345121',
'supportUrl': 'www.support.com',
'zip': '3000'},
'id': '273c1bdf-6258-4484-b6fb-74363721d51f',
'type': 'supplier'}}
Let’s try to register a new supplier.
>>> rv = ses.create_supplier()
Traceback (most recent call last):
...
lino_xl.lib.peppol.utils.PeppolFailure: POST
https://api.ibanity.com/einvoicing/suppliers/ () returned 500
{"errors":[{"code":"internalServerError","detail":"Something went wrong, please
contact the support."}]} (options were {'json': {'data': {'type': 'supplier',
'attributes': {}}}, 'headers': {'Authorization': 'Bearer ...', 'Accept':
'application/vnd.api+json'}})
That’s normal because we must supply some data.
>>> d = {}
>>> d["enterpriseIdentification"] = {
... "enterpriseNumber": "1234567890",
... "vatNumber": "BE1234567890"}
>>> d["contactEmail"] = "contact@example.be"
>>> d["names"] = [{"value": "Company" }, {"value": "Company S.A."}]
>>> d["ibans"] = [{"value": "BE68539007547034"}, {"value": "BE68539007547034"}]
>>> d["city"] = "Eupen"
>>> d["country"] = "Belgium"
>>> d["email"] = "someone@example.com"
>>> d["homepage"] = "www.home.com"
>>> d["phoneNumber"] = "+3287654312"
>>> d["street"] = "Neustraße"
>>> d["streetNumber"] = "123"
>>> d["supportEmail"] = "support@example.be"
>>> d["supportPhone"] = "+3212345121"
>>> d["supportUrl"] = "www.support.com"
>>> d["zip"] = "4700"
>>> d["peppolReceiver"] = True
>>> rv = ses.create_supplier(**d)
>>> pprint(rv)
{'data': {'attributes': {'city': 'Eupen',
'contactEmail': 'contact@example.be',
'country': 'Belgium',
'createdAt': '...',
'email': 'someone@example.com',
'enterpriseIdentification': {'enterpriseNumber': '1234567890',
'vatNumber': 'BE1234567890'},
'homepage': 'www.home.com',
'ibans': [{'id': '...', 'value': 'BE68539007547034'},
{'id': '...', 'value': 'BE68539007547034'}],
'names': [{'id': '...', 'value': 'Company'},
{'id': '...', 'value': 'Company S.A.'}],
'onboardingStatus': 'CREATED',
'peppolReceiver': True,
'phoneNumber': '+3287654312',
'street': 'Neustraße',
'streetNumber': '123',
'supportEmail': 'support@example.be',
'supportPhone': '+3212345121',
'supportUrl': 'www.support.com',
'zip': '4700'},
'id': '...',
'type': 'supplier'}}
>>> rv['data']['type']
'supplier'
The supplier_id of the newly created supplier is here:
>>> new_supplier_id = rv['data']['id']
We don’t test its value here because this value changes each time. But we might use it for retrieving the onboarding status of our new supplier.
>>> rv = ses.get_supplier(new_supplier_id)
>>> assert rv['data']['id'] == new_supplier_id
Unfortunately the Ibanity sandbox doesn’t actually store the new supplier; if we try to get it, we will still just get their plain old demo supplier:
>>> pprint(rv['data']['attributes']['city'])
'Leuven'
>>> pprint(rv['data']['attributes']['names'])
[{'id': '3dffba33-97af-4477-9fab-2d9d2dc31cee', 'value': 'Company'},
{'id': '99e410cc-d6f0-4f36-8096-949741ea8ec3', 'value': 'Company S.A.'}]
Registrations¶
A registration is when an supplier has registered with an Access Point. The List Peppol Registrations returns a list of registrations for a given supplier.
>>> pprint(ses.list_registrations(DEMO_SUPPLIER_ID))
{'data': [{'attributes': {'accessPoints': ['Billit'],
'createdAt': '2023-08-16T12:38:16.662354Z',
'failedSince': '2023-08-16T12:38:16.662354Z',
'modifiedAt': '2023-08-16T12:38:22.575373Z',
'reason': 'already-registered',
'status': 'registration-failed',
'type': 'enterprise-number',
'value': '0143824670'},
'id': '9d12d39d-2b03-4ea6-a770-f5d6b37edea7',
'type': 'peppolRegistration'}]}
This API call requires a supplierId argument, but at least in the sandbox it then ignores this argument:
>>> reg1 = ses.list_registrations("123")
>>> reg2 = ses.list_registrations("456")
>>> reg1 == reg2
True
Documents¶
When talking to the Ibanity API about your invoices and other business documents, you first need to differentiate between “inbound” and “outbound” documents. Compare this to an email client where you have an “inbox” and an “outbox”.
Workflow for outbound documents:
Customer search 🡒 Customer reachability status (Document formats supported by this customer)
Send document 🡒 Receipt
Get feedback (ask status of an outbound document). The status is either successful, in which case we receive a transmissionID, or unsuccessful in which case we receive more details on the reason why it failed.
Workflow for inbound documents:
List suppliers 🡒 list of suppliers
List Peppol inbound documents (1 request per supplier) 🡒
Get Peppol inbound document (1 request per document) 🡒
Some properties are common to both inbound and outbound documents:
attributes.createdAt
: when the document entered the Peppol networkrelationships.supplier
: the Peppol end point who posted this document into the Peppol network. This has nothing to do with the supplier on the invoice (who is called the seller)id
: unique identifier of this document.attributes.transmissionId
: an additional unique identifier within the Peppol network. In case of an issue this can be used in communication with the sending party.the body of the document in UBL format
Outbound documents have three additional properties:
status
: one of {created, sending, sent, invalid, send-error}errors
: one of {invalid-malicious, invalid-format, invalid-xsd, invalid-schematron, invalid-size, invalid-type, error-customer-not-registered, error-document-type-not-supported, error-customer-access-point-issue}type
: one of {“peppolInvoice”, “peppolOutboundDocument”, “peppolOutboundInvoice”, “peppolOutboundCreditNote”}
When asking for a list of documents, you specify a time range. For outbound documents this range means when their status changed (fromStatusChanged and toStatusChanged) while for inbound documents it means their creation time
You cannot create (post) an incoming document.
Outbound documents¶
>>> ar = rt.login("robin")
>>> qs = trading.VatProductInvoice.objects.filter(journal__ref="SLS")
>>> obj = qs.order_by("accounting_period__year", "number").last()
>>> obj
VatProductInvoice #106 ('SLS 10/2015')
>>> xmlfile = obj.make_xml_file(ar)
Make ...lino_book/projects/noi1e/settings/media/xml/2015/SLS-2015-10.xml from SLS 10/2015 ...
>>> res = ses.create_outbound_document(DEMO_SUPPLIER_ID, xmlfile.path)
>>> pprint(res)
{'data': {'attributes': {'createdAt': '2019-07-17T07:31:30.763402Z',
'status': 'created'},
'id': '94884e80-cc4a-4583-bd4a-288095c7876f',
'relationships': {'supplier': {'data': {'id': '273c1bdf-6258-4484-b6fb-74363721d51f',
'type': 'supplier'}}},
'type': 'peppolInvoice'}}
>>> assert res['data']['attributes']['status'] == "created"
>>> assert res['data']['relationships']['supplier']['data']['id'] == DEMO_SUPPLIER_ID
>>> doc_id = res['data']['id']
The sandbox neither validates nor actually creates my document, it always returns a same document id. I can ask it to send another invoice and the sandbox will assign it the same doc_id as before.
>>> obj = trading.VatProductInvoice.objects.get(pk=105)
>>> xmlfile = obj.make_xml_file(ar)
Make ...lino_book/projects/noi1e/settings/media/xml/2015/SLS-2015-9.xml from SLS 9/2015 ...
>>> res = ses.create_outbound_document(DEMO_SUPPLIER_ID, xmlfile.path)
>>> assert res['data']['id'] == doc_id
>>> pprint(res)
{'data': {'attributes': {'createdAt': '2019-07-17T07:31:30.763402Z',
'status': 'created'},
'id': '94884e80-cc4a-4583-bd4a-288095c7876f',
'relationships': {'supplier': {'data': {'id': '273c1bdf-6258-4484-b6fb-74363721d51f',
'type': 'supplier'}}},
'type': 'peppolInvoice'}}
>>> res = ses.get_outbound_document(DEMO_SUPPLIER_ID, doc_id)
>>> pprint(res)
{'data': {'attributes': {'createdAt': '2019-07-17T07:31:30.763402Z',
'errors': None,
'status': 'sent',
'transmissionId': 'ba6c26fa-f47c-4ef1-866b-71e4ef02f15a5'},
'id': '94884e80-cc4a-4583-bd4a-288095c7876f',
'relationships': {'supplier': {'data': {'id': '273c1bdf-6258-4484-b6fb-74363721d51f',
'type': 'supplier'}}},
'type': 'peppolInvoice'}}
>>> assert res['data']['id'] == doc_id
Also here, the sandbox “cheats”, it doesn’t check whether the requested document actually exists, it just returns a document descriptor for the requested id and type.
>>> res = ses.get_outbound_document(DEMO_SUPPLIER_ID, doc_id, credit_note=True)
>>> pprint(res)
{'data': {'attributes': {'createdAt': '2019-07-17T07:31:39.211221Z',
'errors': None,
'status': 'created',
'transmissionId': 'ba6c26fa-f47c-4ef1-866b-71e4ef02f15a5'},
'id': '94884e80-cc4a-4583-bd4a-288095c7876f',
'relationships': {'supplier': {'data': {'id': '273c1bdf-6258-4484-b6fb-74363721d51f',
'type': 'supplier'}}},
'type': 'peppolCreditNote'}}
>>> assert res['data']['id'] == doc_id
>>> doc_id = '12345678-1234-5678-bd4a-1234567890123'
>>> res = ses.get_outbound_document(DEMO_SUPPLIER_ID, doc_id, credit_note=True)
>>> res['data']['id']
'12345678-1234-5678-bd4a-1234567890123'
>>> start_time = datetime.datetime(2020,7,31,0,0,0)
>>> print(start_time.isoformat())
2020-07-31T00:00:00
>>> res = ses.list_outbound_documents(DEMO_SUPPLIER_ID, start_time)
>>> pprint(res)
{'data': [{'attributes': {'createdAt': '2020-07-31T00:00:00',
'status': 'created'},
'id': '94884e80-cc4a-4583-bd4a-288095c7876f',
'relationships': {'supplier': {'data': {'id': 'de142988-373c-4829-8181-92bdaf8ef26d',
'type': 'supplier'}}},
'type': 'peppolInvoice'}],
'meta': {'paging': {'number': 0, 'size': 2000, 'total': 1}}}
Inbound documents¶
>>> res = ses.list_inbound_documents(DEMO_SUPPLIER_ID)
>>> sorted(res.keys())
['data', 'meta']
>>> pprint(res['data'])
[{'attributes': {'createdAt': '...',
'transmissionId': 'c038dbdc1-26ed-41bf-9ebf-37g3c4ceaa58'},
'id': '431cb851-5bb2-4526-8149-5655d648292f',
'relationships': {'supplier': {'data': {'id': 'de142988-373c-4829-8181-92bdaf8ef26d',
'type': 'supplier'}}},
'type': 'peppolInboundDocument'}]
>>> doc_info = res['data'][0]
>>> doc_id = doc_info['id']
>>> res = ses.get_inbound_document_xml(doc_id)
>>> type(res)
<class 'str'>
>>> pprint(res)
('<?xml version="1.0" encoding="UTF-8"?>\n'
' <Invoice '
'xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"\n'
' '
'xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"\n'
' xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">\n'
' </Invoice>')
We can also ask for the json object describing the inbound document, but Lino
doesn’t use this call because it doesn’t provide other information than what was
returned by list_inbound_documents()
:
>>> res = ses.get_inbound_document_json(doc_id)
>>> pprint(res)
{'data': {'attributes': {'createdAt': '...',
'transmissionId': 'c038dbdc1-26ed-41bf-9ebf-37g3c4ceaa58'},
'id': '431cb851-5bb2-4526-8149-5655d648292f',
'relationships': {'supplier': {'data': {'id': 'de142988-373c-4829-8181-92bdaf8ef26d',
'type': 'supplier'}}},
'type': 'peppolInboundDocument'}}
Customer search¶
This is used to check whether my customer exists.
Belgian participants are registered with the Belgian company number, for which identifier 0208 can be used. Optionally, the customer can be registered with their VAT number, for which identifier 9925 can be used.
>>> eas = "9925"
>>> vat_id = "BE0010012671"
>>> res = ses.customer_search(f"{eas}:{vat_id}")
>>> pprint(res)
{'data': {'attributes': {'customerReference': '9925:BE0010012671',
'supportedDocumentFormats': [{'customizationId': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'localName': 'Invoice',
'profileId': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'rootNamespace': 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
'ublVersionId': '2.1'},
{'customizationId': 'urn:cen.eu:en16931:2017#conformant#urn:UBL.BE:1.0.0.20180214',
'localName': 'Invoice',
'profileId': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'rootNamespace': 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
'ublVersionId': '2.1'},
{'customizationId': 'urn:cen.eu:en16931:2017#conformant#urn:UBL.BE:1.0.0.20180214',
'localName': 'CreditNote',
'profileId': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'rootNamespace': 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2',
'ublVersionId': '2.1'},
{'customizationId': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'localName': 'CreditNote',
'profileId': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'rootNamespace': 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2',
'ublVersionId': '2.1'},
{'customizationId': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0',
'localName': 'Invoice',
'profileId': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'rootNamespace': 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
'ublVersionId': '2.1'},
{'customizationId': 'urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0',
'localName': 'CreditNote',
'profileId': 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'rootNamespace': 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2',
'ublVersionId': '2.1'}]},
'id': '99988e77-cc4a-4583-bd4a-288095c5566e',
'type': 'peppolCustomerSearch'}}
The Flowin sandbox contains hard-coded fake data. Using another reference than
'9925:BE0010012671'
as customerReference will in result a 404 response
even when you specify a valid VAT number:
>>> res = ses.customer_search(f"9925:BE0433670865")
...
Traceback (most recent call last):
...
lino_xl.lib.peppol.utils.PeppolFailure: POST
https://api.ibanity.com/einvoicing/peppol/customer-searches () returned 404
{"errors":[{"code":"validationError","detail":"Customer reference MUST be of the
form
schemeId:endpointId","source":{"pointer":"/data/attributes/customerReference"}}]}
(options were {'headers': {'Authorization': 'Bearer ...', 'Accept':
'application/vnd.api+json'}, 'json': {'data': {'type': 'peppolCustomerSearch',
'attributes': {'customerReference': '9925:BE0433670865'}}}})