Generate the QR code in your system
This is the recommended issuing integration for Australian and New Zealand v2 payslips. Prepare the payslip with the helper for its jurisdiction. The SDK selects the matching schema and PII format, validates non-PII fields, and encrypts PII in your system. Send only non-PII data and encryption metadata to Verifiabl. Make the QR code in your system.
Cannot generate the QR code in your system? Use the alternative guide to have Verifiabl return a ready-made PNG. That integration sends the encrypted PII to Verifiabl. See the Verifiabl-generated QR code guide.
Follow the Node, .NET or Ruby tabs below. For another platform, call the endpoints directly using the Issuer API reference.
npm install @verifiabl/issuer@0.30.0dotnet add package Verifiabl.Issuer --version 0.15.0gem install verifiabl-issuer --version 0.3.0Ruby requires version 3.3 or newer.
Complete examples: Node · .NET · Ruby
1. Authenticate
Section titled “1. Authenticate”Create a client with the client ID and the client secret from your onboarding. The SDK exchanges them for a short-lived access token, and gets a new token when necessary.
import { VerifiablClient } from "@verifiabl/issuer";
const clientId = process.env.VERIFIABL_CLIENT_ID?.trim();
const clientSecret = process.env.VERIFIABL_CLIENT_SECRET?.trim();
if (!clientId || !clientSecret) {
throw new Error("Set VERIFIABL_CLIENT_ID and VERIFIABL_CLIENT_SECRET");
}
const client = new VerifiablClient({
environment: "sandbox",
auth: { clientId, clientSecret },
});using Verifiabl;
using Verifiabl.Client;
string clientId = Environment.GetEnvironmentVariable("VERIFIABL_CLIENT_ID")?.Trim()
?? throw new InvalidOperationException("Set VERIFIABL_CLIENT_ID");
string clientSecret = Environment.GetEnvironmentVariable("VERIFIABL_CLIENT_SECRET")?.Trim()
?? throw new InvalidOperationException("Set VERIFIABL_CLIENT_SECRET");
if (clientId.Length == 0 || clientSecret.Length == 0)
{
throw new InvalidOperationException("Set VERIFIABL_CLIENT_ID and VERIFIABL_CLIENT_SECRET");
}
var client = new VerifiablClient(new VerifiablClientOptions
{
Environment = VerifiablEnvironment.Sandbox,
Auth = VerifiablAuth.ClientCredentials(clientId, clientSecret),
});require "verifiabl/issuer"
issuer = Verifiabl::Issuer::Client.new(
Verifiabl::Issuer::Configuration.new(
environment: :sandbox, # or :production
client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET")
)
)To call the token endpoint directly, refer to Authentication in the reference. It shows the OAuth2 client-credentials exchange.
2. Prepare the payslip
Section titled “2. Prepare the payslip”Give the jurisdiction-specific helper the employee’s PII, non-PII payslip data, issue time, and encryption key. The helper validates non-PII data, formats the matching PII profile, and encrypts locally. Keep the prepared result for registration and barcode rendering. Do not log or store plaintext PII.
import { prepareAustralianV2Payslip } from "@verifiabl/issuer";
const encodedKey = process.env.VERIFIABL_ENCRYPTION_KEY_BASE64;
if (!encodedKey) throw new Error("Set VERIFIABL_ENCRYPTION_KEY_BASE64");
const key = Buffer.from(encodedKey, "base64");
const prepared = prepareAustralianV2Payslip({
pii: {
employeeName: "Jane A. Doe", employerName: "Example Payroll Pty Ltd",
employerAbn: "12 345 678 901",
address: { lines: ["12 Example St"], suburb: "Sydney", stateOrTerritory: "NSW", postcode: "2000" },
},
payslipNonPii: {
periodEnd: "2026-08-31", paymentDate: "2026-09-04", currency: "AUD",
gross: "9000.00", paygw: "2250.00", net: "6750.00",
},
issuedAt: new Date().toISOString(), key,
});using Verifiabl;
using Verifiabl.Client;
byte[] key = Convert.FromBase64String(
Environment.GetEnvironmentVariable("VERIFIABL_ENCRYPTION_KEY_BASE64")!);
PreparedV2Payslip prepared = V2Issuance.PrepareAustralian(
pii: new AustralianPiiFields
{
EmployeeName = "Jane A. Doe", EmployerName = "Example Payroll Pty Ltd",
EmployerAbn = "12 345 678 901",
},
payslip: new AustralianPayslipV2
{
PeriodEnd = new DateOnly(2026, 8, 31), PaymentDate = new DateOnly(2026, 9, 4),
Currency = PayslipCurrencies.Aud,
Gross = 9000.00m, Paygw = 2250.00m, Net = 6750.00m,
},
issuedAt: DateTimeOffset.UtcNow, key: key);require "base64"
key = Base64.strict_decode64(ENV.fetch("VERIFIABL_ENCRYPTION_KEY_BASE64"))
prepared = Verifiabl::Issuer.prepare_australian_v2_payslip(
pii: {
employee_name: "Jane A. Doe", employer_name: "Example Payroll Pty Ltd",
employer_abn: "12 345 678 901",
address: {lines: ["12 Example St"], suburb: "Sydney", state_or_territory: "NSW", postcode: "2000"}
},
payslip_non_pii: {
period_end: "2026-08-31", payment_date: "2026-09-04", currency: "AUD",
gross: "9000.00", paygw: "2250.00", net: "6750.00"
},
issued_at: Time.now.utc, key: key
)The .NET example uses DateOnly on .NET 8 or newer. On .NET Framework 4.7.2, use YYYY-MM-DD strings for payslip dates.
If you do not use the SDK, make the same pipe-delimited plaintext and use the same encryption. Refer to PII serialisation format and Encryption in the reference.
3. Register the record
Section titled “3. Register the record”Before sending a request, atomically store the prepared registration and ciphertext with your payslip. Obtain the ciphertext from prepared.barcodeParts(prepared.verifiablReference).encryptedPii (Node), prepared.BarcodeParts(prepared.VerifiablReference).EncryptedPii (.NET), or prepared.barcode_parts(prepared.verifiabl_reference).fetch(:encrypted_pii) (Ruby). Store it as binary data with suitable access controls; never persist plaintext PII or the encryption key. Send the prepared registration to registerNonPII. The registration contains the reference, IV and tag but not the ciphertext. After a process restart, retry with the exact saved registration and build the QR from the saved ciphertext and the reference returned by the API—do not re-encrypt for an idempotent retry.
// Atomically persist both values as binary data before sending.
const savedRegistration = prepared.registration;
const savedCiphertext = prepared.barcodeParts(prepared.verifiablReference).encryptedPii;
// On restart, resend savedRegistration unchanged and render using savedCiphertext.
const result = await client.registerNonPii(savedRegistration);// Atomically persist the registration and binary ciphertext before sending.
RegisterNonPiiRequest savedRegistration = prepared.Registration;
byte[] savedCiphertext = prepared.BarcodeParts(prepared.VerifiablReference).EncryptedPii;
// On restart, resend savedRegistration unchanged and render using savedCiphertext.
RegisterNonPiiResponse result = await client.RegisterNonPiiAsync(savedRegistration);# Atomically persist the registration and binary ciphertext before sending.
saved_registration = prepared.registration
saved_ciphertext = prepared.barcode_parts(prepared.verifiabl_reference).fetch(:encrypted_pii)
# On restart, resend saved_registration unchanged and render using saved_ciphertext.
result = issuer.register_non_pii(**saved_registration)The SDKs retry registration with the same reference after temporary failures. Do not prepare and encrypt the record again for an idempotent retry.
The examples use au.payslip.v2 and the AU2 PII format. For New Zealand, use prepareNewZealandV2Payslip (Node), V2Issuance.PrepareNewZealand (.NET), or prepare_new_zealand_v2_payslip (Ruby). Map the printed IRD number to the NZ PII fields. Use paye instead of paygw and set the payslip’s currency, for example NZD. The helper selects nz.payslip.v2 and NZ2. See Payslip data for both schemas. Keep employee PII out of non-PII fields.
4. Build and embed the QR
Section titled “4. Build and embed the QR”Make the QR code in your system with the returned reference and the prepared result’s barcode parts. If the process has restarted, use the saved ciphertext with the returned reference instead; the saved registration alone cannot reconstruct the encrypted PII. Then put it on the payslip PDF, usually in the bottom-right corner. The QR code spans the full badge width, so the badge does not include its own quiet zone on the left, right or bottom. Keep a clear light margin of at least one tenth of the badge width on those three sides. Do not place it flush against a page edge, border, text or dark content. Make the QR code sufficiently large: a scanner must read it after you print the payslip.
import { createBarcodeSvg } from "@verifiabl/issuer";
const parts = { verifiablReference: result.verifiablReference, encryptedPii: savedCiphertext };
const badge = createBarcodeSvg(parts, { environment: "sandbox" });
const svg = badge.svg;BarcodeParts parts = new(result.VerifiablReference, savedCiphertext);
BarcodeSvgResult badge = VerifiablBarcode.CreateSvg(
parts, new BarcodeSvgOptions { Environment = VerifiablEnvironment.Sandbox });
string svg = badge.Svg;parts = {verifiabl_reference: result.verifiabl_reference, encrypted_pii: saved_ciphertext}
barcode = Verifiabl::Issuer.build_barcode_svg(**parts, environment: :sandbox)
svg = barcode.svgThe QR code contains a scan URL. The URL keeps the reference in the path and the encrypted PII in the fragment: https://v.verifiabl.io/v/<verifiablReference>#2.<BASE32>. The environment option selects the host in that URL: v.verifiabl.io in production, or v.sandbox.verifiabl.io in sandbox. Give this option the same value as the client.
On a platform with no SDK, make the URL in your system, then encode it with a supported QR library. Keep the encrypted PII in the fragment, after the # character. A browser does not send the fragment to a server, and thus the encrypted PII stays out of the server logs. Refer to QR code payload in the reference for the format and the rules.
5. Add the metadata copy
Section titled “5. Add the metadata copy”Add the matching pipe-delimited payload to the PDF’s XMP metadata. This preserves a copy if the QR code is lost during processing. The metadata value is 2|<verifiablReference>|<BASE32>, not the scan URL.
import { buildBarcodePayload } from "@verifiabl/issuer";
const xmpPayload = buildBarcodePayload(parts);string xmpPayload = VerifiablBarcode.BuildPayload(parts);xmp_payload = Verifiabl::Issuer.build_barcode_payload(
**parts
)The value is the encrypted payload. It does not contain plaintext PII. Refer to PDF metadata copy for the XMP namespace, the property name, and the SDK constants for the two values.
Batch registration
Section titled “Batch registration”For a pay run, prepare each record with its jurisdiction-specific helper. Persist each registration and its matching ciphertext together before sending, as for a single payslip. Register a maximum of 1,000 records in one request with registerNonPIIBatch. Results match the order of the records you sent. An invalid record does not fail the batch. Send an optional externalId to identify a record in the results. Pair each successful result with the same prepared result when you make its QR code.
import { prepareAustralianV2Payslip, prepareNewZealandV2Payslip } from "@verifiabl/issuer";
const issuedAt = new Date().toISOString();
const batchPrepared = payslips.map((payslip) =>
payslip.country === "AU"
? prepareAustralianV2Payslip({ pii: payslip.auPii, payslipNonPii: payslip.auNonPii, issuedAt, key })
: prepareNewZealandV2Payslip({ pii: payslip.nzPii, payslipNonPii: payslip.nzNonPii, issuedAt, key }),
);
// Persist each batchPrepared reference, registration, and ciphertext before sending.
const batchResponse = await client.registerNonPiiBatch({
records: batchPrepared.map((item, i) => ({
...item.registration,
verifiablReference: item.verifiablReference,
externalId: payslips[i].externalId,
})),
});
batchResponse.results.forEach((result, i) => {
if (result.status === "created" || result.status === "duplicate") {
const parts = batchPrepared[i].barcodeParts(result.verifiablReference);
// Build this record's QR code from parts.
} else {
// Handle result.code. Do not parse result.detail.
}
});DateTimeOffset issuedAt = DateTimeOffset.UtcNow;
var batchPrepared = payslips.Select(payslip => payslip.Country == "AU"
? V2Issuance.PrepareAustralian(payslip.AustralianPii, payslip.AustralianPayslip, issuedAt, key)
: V2Issuance.PrepareNewZealand(payslip.NewZealandPii, payslip.NewZealandPayslip, issuedAt, key)
).ToList();
// Persist each registration and its matching ciphertext before sending.
RegisterNonPiiBatchResponse batchResponse = await client.RegisterNonPiiBatchAsync(
batchPrepared.Select((item, index) => new BatchRecord
{
VerifiablReference = item.VerifiablReference,
Schema = item.Registration.Schema,
IssuedAt = item.Registration.IssuedAt,
PayslipNonPii = item.Registration.PayslipNonPii,
EncryptionMetadata = item.Registration.EncryptionMetadata,
ExternalId = payslips[index].ExternalId,
}).ToList());
for (int i = 0; i < batchResponse.Results.Count; i++)
{
BatchRecordResult result = batchResponse.Results[i];
if (result.Status == BatchRecordStatuses.Created || result.Status == BatchRecordStatuses.Duplicate)
{
BarcodeParts parts = batchPrepared[i].BarcodeParts(result.VerifiablReference);
// Build this record's QR code from parts.
}
else { /* Handle result.Code. Do not parse result.Detail. */ }
}batchPrepared = payslips.map do |payslip|
method = (payslip.fetch(:country) == "AU") ? :prepare_australian_v2_payslip : :prepare_new_zealand_v2_payslip
Verifiabl::Issuer.public_send(method, pii: payslip.fetch(:pii),
payslip_non_pii: payslip.fetch(:non_pii), issued_at: Time.now.utc, key: key)
end
# Persist each batchPrepared reference and registration before sending.
records = batchPrepared.each_with_index.map do |item, index|
item.registration.merge(external_id: payslips.fetch(index).fetch(:external_id))
end
batch = issuer.register_non_pii_batch(records)
batch.results.each_with_index do |result, index|
if %w[created duplicate].include?(result.status)
parts = batchPrepared.fetch(index).barcode_parts(result.verifiabl_reference)
# Build this record's QR code from parts.
else
# Handle result.code. Do not parse result.detail.
end
endThe Verifiabl reference is the idempotency key. You can send the same batch again after a timeout or a 5xx response. For the result and the error codes of each record, refer to Batch registration in the reference.