Graphql schema documentation
link Disclaimers
link Overview
The relay computer system handles the validation of tickets at multiple entry zones belonging to different organizations. By using this computer system cross-organization tickets can be used and reported on.
The API is available at the endpoint https://relay.gomus.cloud/graphql. In order to talk to the API you will need to send GraphQL queries over HTTPS. You can learn more about the GraphQL query language in the official documentation.
Although it's possible to create queries without any additional software, it's highly recommended to use a GUI for this purpose. This will allow the developer to take advantage of powerful features like autocompletion and syntax check. Our recommendation is: GraphQL IDE
link Structure
All the data is grouped into different organizations. Each organizations can have multiple products. A product defines basic validity information. A product has multiple variants (think regular, child, ...). In order to control entry and exit locations each organization has multiple entry zones. These entry zones can be grouped together. A group can be assigned to a product.
Example: Organization "Awesome Museum" has a product "day ticket" in two variants "regular" and "child". Awesome Museum defines two entry zones "left door" and "right door". Both can be used by visitors, but each visitor only has a single entry. So the entry zones are grouped together and the group is assigned to the product with the limit of only one entry.
When a ticket is sold by a 3rd party or by the organization itself then this is communicated to the relay system. The 3rd party or organization creates a ticket identifier itself and sends this to the API.
When a visitor actually visits his ticket is scanned and the ticket identifier is validated against the API. This will create scan events which can later be reported on.
link Authorization
You will need to authorize yourself in every request to the API.
The API expects you to send an API token. The API uses standardized JSON Web Tokens. You can read about them here. The API tokens have a limited validity (currently 24h). So you are expected to renew your token regularly.
You can create a new API token by sending your username and password in userLogin mutation. This is detailed below.
Getting an API token
CURL example:
curl --location 'https://relay.gomus.cloud/graphql.json' \
--header 'Content-Type: application/json' \
--data-raw '{"query":"mutation userLogin($email:String!, $password:String!) {\n userLogin(input: {email: $email, password: $password}) {\n user {\n authToken\n }\n }\n}","variables":{"password":"needful4travel@stamp","email":"relay+test@giantmonkey.de"}}'
In the response you will get your JWT token. This token is currently valid for one day.
Authorizing with API token
Send HTTP Authorization header.
"Authorization: Bearer $YOUR_TOKEN"
link Validating a Ticket (Info only)
Goal: We have scanned a ticket identifier and want to validate with the relay API.
Requirements: identifier, entryZoneId, deviceIdentifier, kind (of action), scannedAt (time), info (boolean)
Suppose we just decoded a QR code and the ticket identifier is 123. Let's check this ticket identifier against the API. We will create a scan event. You need to supply the entry zone id (see above), a unique device identifier, the kind of action (here "entry") and a time.
The device identifier should be a unique value per hardware device. This is later used to group scan events per device. You can freely set a value here as long as it is unique and stays the same per device.
If info is set to `true`, then you get back information about the ticket. But the ticket validity in the system is not changed by this request.
Request
mutation {
scanEventCreate(input: {
identifier: "123",
entryZoneId: "RW50cnlab25lLU5ZUGh6Sw==",
deviceIdentifier: "myDevice",
kind: entry,
info: true,
scannedAt: "2018-09-01T15:00:00Z"
organizationId: "T3JnYW5pfmF0bW9uLWySYkZlOQ=="
}) {
scanEvent {
id
kind
details
}
errors {
key
messages
}
}
}
See scanEventCreateInput for more details on the input attributes.
If you want to get more information back about the scanEvent, then you can request more attributes with GraphQL. See ScanEvent for more details on the returned object.
Response
{
"data": {
"scanEventCreate": {
"scanEvent": {
"id": "6",
"kind": "entry",
"details": "success"
},
"errors": null
}
}
}
The response returned the attributes id, kind and details because they are the ones we requested. We could have requested other attributes to be returned. See ScanEvent for more details on the returned object.
In that case the requested entry was successful. The system has recorded the entry and the ticket now has one use less. If it was a single use ticket then it cannot be used again.
On Errors
In normal operations errors will be null. The errors attribute is only set in the response if the request could not be parsed or could not be executed.
On Details
In case of normal execution you will get back detailed information about your request in the details attribute. The following values are possible:
- success - ticket was successfully validated
- ticket_not_found - there is no ticket with the specified identifier
- inaccessible_date - ticket is not valid on specified date
- inaccessible_entry_zone - ticket is not valid on specified entry zone
link Voiding a Ticket
Goal: We have scanned a ticket identifier and want to void with the relay API.
Requirements: identifier, entryZoneId, deviceIdentifier, kind (of action), scannedAt (time)
Suppose we just decoded a QR code and the ticket identifier is 123. Let's check this ticket identifier against the API and void if valid. We will create a scan event. You need to supply the entry zone id, a unique device identifier, the kind of action (here "entry") and a time.
The device identifier should be a unique value per hardware device. This is later used to group scan events per device. You can freely set a value here as long as it is unique and stays the same per device.
If info is set to `false`, then the system will consume one usage of the ticket if it is valid.
Request
mutation {
scanEventCreate(input: {
identifier: "123",
entryZoneId: "RW50cnlab25lLU5ZUGh6Sw==",
deviceIdentifier: "myDevice",
kind: entry,
info: false,
scannedAt: "2018-09-01T15:00:00Z"
organizationId: "T3JnYW5pfmF0bW9uLWySYkZlOQ=="
}) {
scanEvent {
id
kind
details
}
errors {
key
messages
}
}
}
See scanEventCreateInput for more details on the input attributes.
Response
{
"data": {
"scanEventCreate": {
"scanEvent": {
"id": "6",
"kind": "entry",
"details": "success"
},
"errors": null
}
}
}
See ScanEvent for more details on the returned object.
In that case the requested entry was successful. The system has recorded the entry and the ticket now has one use less. If it was a single use ticket then it cannot be used again.link Creating a Ticket
Goal: We have sold a ticket and want to tell the relay about it.
Requirements: ticket identifier, productVariantId, validFrom, validUntil, customerIdentifier
We send the information about the ticket to the relay system so that it can be validated and voided later on by other systems.
When sending the ticket we need to refer to a known product variant. The corresponding product and product variant needs to be created in the system before we can create tickets for them.
Tickets with Fixed Time
The most common use case is to define ticket validity when sending the ticket information to the relay. In that case the corresponding product needs to be setup with kind: fixed_time.
Tickets with Activation on First Use
The product can be configured as kind: activated_on_first_use. In that case validFrom and validUntil can be omitted in the request below. The relay will then set these attributes when the ticket is voided for the first time.
On Customer Identifier
In order to help 3rd party systems to group scan events and reports per customer, 3rd party systems can send an opaque customer identifier. This identifier should be unique per customer. The relay treats this value as an opaque blob and will output what was input before and will not do any processing on this value itself.
It is forbidden to put data here that can directly identify customers. It is only allowed to put references here that can be later used together with other systems. Forbidden would be "Max Mustermann, Maxstreet 13, 10119 Berlin". Allowed would be "customer_128727232".
Request
mutation {
ticketCreate(input: {
identifier: "423xprvEthXWTJWSefde",
productVariantId: "UHJvZHVjdC0wbXFVbjA=",
validFrom: "2018-09-01T10:00:00Z",
validUntil: "2018-09-01T12:00:00Z",
metadata: "{\"sample key\": \"sample value\"}"
customerIdentifier: "myCustomerIdentifier"
}) {
ticket {
identifier
}
errors {
key
messages
}
}
}
See ticketCreateInput for more details on the input attributes.
Response
{
"data": {
"ticketCreate": {
"ticket": {
"identifier": "423xprvEthXWTJWSefde",
},
"errors": null
}
}
}
See Ticket for more details on the returned object.
link Creating a Ticket with Encrypted Code
Goal: We have sold a ticket and want to tell the relay about it. The final ticket should have an encrypted code that can be validated offline.
Requirements: ticket identifier, productVariantId, validFrom, validUntil, customerIdentifier
Creating a ticket with encrypted code works just as above except that we explicitly request an encryptedCode from the relay. The system will then use public-key authenticated encryption to encrypt information about the ticket with a private key that is only known to the relay system. Authentication and decryption on clients is done with the public key.
Therefore everybody can validate tickets but only the relay system can create working encrypted ticket codes. The implementation uses the industry standard software library NaCl.
Request
mutation {
ticketCreate(input: {
identifier: "423xprvEthXWTJWSefde",
productVariantId: "UHJvZHVjdFZhcmlhbnQtR1lac0xM",
validFrom: "2018-09-01T10:00:00Z",
validUntil: "2018-09-01T12:00:00Z",
customerIdentifier: "myCustomerIdentifier"
}) {
ticket {
identifier
encryptedCode
}
errors {
key
messages
}
}
}
See ticketCreateInput for more details on the input attributes.
Response
{
"data": {
"ticketCreate": {
"ticket": {
"identifier": "423xprvEthXWTJWSefde",
"encryptedCode": "..............(long base64 encoded text)............."
},
"errors": null
}
}
}
See Ticket for more details on the returned object.
Using the encrypted code
The encrypted code is transmitted as text encoded with base64. First you need to base64-decode the code.
You can then print the resulting value as a QR-code on your tickets.
All scanners need to decrpyt the code before validating against the API. If offline info was encrypted then offline validation can be used. If not then the decrypted ticket identifier needs to be send to the API as normal.
This document explains how relay encodes information in qrcodes.
Modes
- Unencrypted ticket identifier (legacy)
- Signed ticket identifier
- Signed ticket identifier + signed offline data
Mode 1, unencrypted ticket identifier, is present for legacy systems that may have already printed tickets. In this case it is assumed that the whole payload is a ticket identifier. The ticket identifier can be checked with the relay graphql API.
Mode 2, signed ticket identifier without offline data, should be used when the size of the qrcode is restricted and Mode 3 creates codes, that are too big.
Mode 3, signed ticket identifier with offline data, is the recommended mode. With this mode a reduced offline validation is possible, because the entry authorizations are encoded into qrcode and can be read when offline. Synchronization with the server happens asynchronously then.
Public-key signatures
The information inside of qrcodes created by relay is signed and authenticated with https://doc.libsodium.org/public-key_cryptography/public-key_signatures
- This ensures that only the relay server can create relay codes with its private key.
- All clients can verify with a public key.
- We use combined mode
- Public keys are exposed by the
ticketSigningatureVerifyKeysendpoint.
Keys
Encryption keys were generated once and they are in use until they get compromised. Keys are published via
GraphQL interface (ticketSigningatureVerifyKeys and ticketEncryptionKeys).
QRcode
We encode the information in Mode 2 and 3 with msgpack, https://msgpack.org/, before encrypting it. In Mode 1 there is no encoding.
The format is (in pseudocode)
"\x37\x1F\x8B\x02" + [TICKET_IDENTIFIER, PRODUCT_VARIANT_ID, MAX_PAX, VALID_FROM, VALID_UNTIL, [PAYLOAD*]].to_msgpack.zip.sign.encrypt
We are using libsodium for signing (Ed25519) and for encrypting (XSalsa20Poly1305).
- \x37\x1F\x8B\x02 is the beginning of our code and \x02 is the encoding version (2).
- TICKET_IDENTIFIER is always present.
- PRODUCT_VARIANT_ID is always present (warning: this is only an ID, client needs to download all product variants beforehand in order to display their names)
- VALID_FROM is a unix timestamp, null means unlimited
- VALID_UNTIL is a unix timestamp, null means unlimited
- PAYLOAD may be zero, one, or more payload objects in an array which is always present.
Payload objects
We want to encode multiple different payloads. Each payload therefore has type to identify it.
The payloads are encoded as:
[TYPE, ELEMENT*]
- TYPE is an integer that identifies the payload types. See types below.
- ELEMENT may be zero, one, or more attributes for the payload. See example below.
Payload Types
We start with only the single type "Entry Authorization" defined.
- 1: Entry Authorization
Other types we might add later might be Coupon, Rabatt, etc.
Entry Authorization
- Entries left (integer): 1
- Allowed entry locations (array of strings): ['RW50cnlab25lLU5temh5Zw==', 'RW50cnlab25lLU52QWhWVg==', 'RW50cnlab25lLTdreWg1YQ==']
this would be encoded as
[1, ['RW50cnlab25lLU5temh5Zw==', 'RW50cnlab25lLU52QWhWVg==', 'RW50cnlab25lLTdreWg1YQ==']].to_msgpack
Examples
Notice: no payload was encoded. This indicates Mode 2.
Ticket with offline data
- Ticket identifier (string): 2030ZARm7pmAmoajD
- Product Variant ID (string): UHJvZHVjdFZhcmlhbnQtZWEwc2tP
- Valid from (timestamp): 1601969400
- Valid until (timestamp): 1634162399
- Entries left (integer): 1
- Allowed entry locations (array of strings): 'RW50cnlab25lLU5temh5Zw==', 'RW50cnlab25lLU52QWhWVg==', 'RW50cnlab25lLTdreWg1YQ=='
this would be encoded as
["2030ZARm7pmAmoajD", "UHJvZHVjdFZhcmlhbnQtZWEwc2tP", 1, 1601969400, 1634162399, [[1, ['RW50cnlab25lLU5temh5Zw==', 'RW50cnlab25lLU52QWhWVg==', 'RW50cnlab25lLTdreWg1YQ==']]]].to_msgpack
(138 bytes)
Empty offline data
Attention, if you encode
["2030ZARm7pmAmoajD", "UHJvZHVjdFZhcmlhbnQtZWEwc2tP", 1, 1601969400, 1634162399, []].to_msgpack
that defines a ticket with no entry authorizations.