AccessGrid is an Elixir SDK for interacting with the AccessGrid.com API. This SDK provides a simple interface for managing NFC key cards, card templates, landing pages, credential profiles, webhooks, HID Origo organizations, and ledger items. Full docs at https://www.accessgrid.com/docs.
Contents
- Installation
- Configuration
- Quick Start
- API Reference
- Utilities
- Error Handling
- Testing
- Security
- Contributing
- Development
- License
Installation
Add accessgrid to your list of dependencies in mix.exs:
def deps do
[
{:accessgrid, "~> 0.1.0"}
]
endConfiguration
The SDK reads credentials from your application config. Add them in config/runtime.exs so environment variables are picked up at boot:
# config/runtime.exs
import Config
config :accessgrid,
account_id: System.get_env("ACCESSGRID_ACCOUNT_ID"),
api_secret: System.get_env("ACCESSGRID_API_SECRET")
For static values (set at compile time) use config/config.exs instead. Either file works — the SDK doesn't care where the config came from.
With credentials in config, every SDK call resolves them automatically:
{:ok, card} = AccessGrid.AccessPasses.get("card_id")This is the default path for single-tenant apps.
Using a custom client
For multi-tenant scenarios, testing, or scripted operations against multiple accounts, pass credentials explicitly via AccessGrid.Client.new/1:
client = AccessGrid.Client.new(
account_id: "your_account_id",
api_secret: "your_api_secret"
)
{:ok, card} = AccessGrid.AccessPasses.get("card_id", client: client)
Every SDK function accepts client: as an option; when omitted, it falls back to the project config.
Quick Start
# Issue a new card
{:ok, card} = AccessGrid.AccessPasses.issue(%{
card_template_id: "template_id",
full_name: "Employee Name",
email: "employee@company.com"
})
IO.puts("Install URL: #{card.install_url}")API Reference
Access passes
Issue a new card
{:ok, card} = AccessGrid.AccessPasses.issue(%{
card_template_id: "template_id",
employee_id: "123456789",
card_number: "16187",
site_code: "100",
full_name: "Employee Name",
email: "employee@yourwebsite.com",
phone_number: "+19547212241",
classification: "full_time",
start_date: "2025-01-31T22:46:25.601Z",
expiration_date: "2025-04-30T22:46:25.601Z",
employee_photo: AccessGrid.Utils.base64_file!("path/to/photo.png"),
metadata: %{department: "Engineering"}
})
IO.puts("Install URL: #{card.install_url}")Get a card
{:ok, card} = AccessGrid.AccessPasses.get("card_id")
IO.puts("Card ID: #{card.id}")
IO.puts("State: #{card.state}")
IO.puts("Full Name: #{card.full_name}")
IO.puts("Install URL: #{card.install_url}")
IO.puts("Expiration Date: #{card.expiration_date}")
IO.puts("Card Number: #{card.card_number}")
IO.puts("Site Code: #{card.site_code}")
IO.puts("Devices: #{length(card.devices)}")
IO.puts("Metadata: #{inspect(card.metadata)}")Update a card
{:ok, card} = AccessGrid.AccessPasses.update("card_id", %{
employee_id: "987654321",
full_name: "Updated Employee Name",
classification: "contractor",
expiration_date: "2025-02-22T21:04:03.664Z"
})List cards
# List all cards for a template
{:ok, cards} = AccessGrid.AccessPasses.list("template_id")
# List cards filtered by state
{:ok, active_cards} = AccessGrid.AccessPasses.list("template_id", state: "active")Manage card states
# Suspend a card
{:ok, card} = AccessGrid.AccessPasses.suspend("card_id")
# Resume a card
{:ok, card} = AccessGrid.AccessPasses.resume("card_id")
# Unlink a card
{:ok, card} = AccessGrid.AccessPasses.unlink("card_id")
# Delete a card
{:ok, card} = AccessGrid.AccessPasses.delete("card_id")Card templates
Create a template
All template fields are flat — no design: or support_info: wrappers. Pair image params with AccessGrid.Utils.base64_file!/1.
{:ok, result} = AccessGrid.Console.create_template(%{
name: "Employee NFC key",
platform: "apple",
use_case: "corporate_id",
protocol: "desfire",
allow_on_multiple_devices: true,
watch_count: 2,
iphone_count: 3,
background_color: "#FFFFFF",
label_color: "#000000",
label_secondary_color: "#333333",
background: AccessGrid.Utils.base64_file!("path/to/background.png"),
logo: AccessGrid.Utils.base64_file!("path/to/logo.png"),
icon: AccessGrid.Utils.base64_file!("path/to/icon.png"),
support_url: "https://help.yourcompany.com",
support_phone_number: "+1-555-123-4567",
support_email: "support@yourcompany.com",
privacy_policy_url: "https://yourcompany.com/privacy",
terms_and_conditions_url: "https://yourcompany.com/terms",
credential_profiles: ["cp_ex_id_1"],
landing_pages: ["lp_ex_id_1"],
metadata: %{version: "1.0"}
})
IO.puts("Template ID: #{result.id}")
IO.puts("Estimated Publishing: #{result.estimated_publishing_date}")Update a template
{:ok, result} = AccessGrid.Console.update_template("template_id", %{
name: "Updated Employee NFC key",
watch_count: 3,
support_url: "https://help.yourcompany.com",
support_email: "newsupport@yourcompany.com"
})Read a template
The same endpoint serves both single templates and template pairs. Pattern match on the returned struct to tell them apart.
case AccessGrid.Console.read_template("template_id") do
{:ok, %AccessGrid.CardTemplate{} = template} ->
IO.puts("Name: #{template.name}")
IO.puts("Platform: #{template.platform}")
IO.puts("Background color: #{template.background_color}")
IO.puts("Support email: #{template.support_email}")
IO.puts("Watch count: #{template.watch_count}")
IO.puts("Allow on multiple devices: #{template.allow_on_multiple_devices}")
IO.puts("Issued keys: #{template.issued_keys_count}")
IO.puts("Active keys: #{template.active_keys_count}")
IO.puts("Credential profiles: #{inspect(template.credential_profiles)}")
IO.puts("Landing pages: #{inspect(template.landing_pages)}")
{:ok, %AccessGrid.CardTemplatePair{} = pair} ->
IO.puts("Pair: #{pair.name}")
Enum.each(pair.templates, fn t -> IO.puts(" - #{t.platform}: #{t.id}") end)
endGet event logs
{:ok, events, pagination} = AccessGrid.Console.get_logs("template_id",
page: 1,
per_page: 50,
filters: %{
device: "mobile",
start_date: "2025-01-01T00:00:00Z",
end_date: "2025-01-31T23:59:59Z",
event_type: "access_pass.installed"
}
)
Enum.each(events, fn event ->
IO.puts("#{event.created_at}: #{event.event}")
end)
IO.puts("Page #{pagination["current_page"]} of #{pagination["total_pages"]}")iOS preflight
Returns the Apple In-App Provisioning preflight bundle for an access pass.
{:ok, preflight} = AccessGrid.Console.ios_preflight(
"template_id",
%{access_pass_ex_id: "ap_abc123"}
)
IO.puts("Provisioning credential: #{preflight.provisioning_credential_identifier}")
IO.puts("Sharing instance: #{preflight.sharing_instance_identifier}")
IO.puts("Card template: #{preflight.card_template_identifier}")
IO.puts("Environment: #{preflight.environment_identifier}")Publish a template
For Android+SEOS templates, Rails also syncs the template to the HID portal. If the sync fails the template rolls back to draft and the call returns {:error, :validation_failed, _}.
{:ok, result} = AccessGrid.Console.publish_template("template_id")
IO.puts("Template #{result.id} status: #{result.status}")
# status is one of: "publishing" (already in flight), "in-review" (Apple
# queued), or "ready" (Android immediate)Reveal SmartTap credentials
Returns the template's smart_tap_key encrypted with your ephemeral public key. Each request needs a fresh keypair (Rails enforces single-use by fingerprint, and rate-limits to 1 per minute per account).
# Generate an ephemeral EC keypair (e.g. via openssl)
{pub_pem, 0} = System.cmd("openssl", ["ec", "-in", "priv.pem", "-pubout"])
{:ok, reveal} = AccessGrid.Console.reveal_smart_tap(
"template_id",
%{client_public_key: pub_pem}
)
IO.puts("Key version: #{reveal.key_version}")
IO.puts("Collector ID: #{reveal.collector_id}")
IO.puts("Fingerprint: #{reveal.fingerprint}")
IO.inspect(reveal.encrypted_private_key, label: "envelope")
# encrypted_private_key is a map: %{"alg" => ..., "ephemeral_public_key" => ..., "iv" => ..., "ciphertext" => ..., "tag" => ...}
# Decrypt with your matching private key.
If you retry with the same public key, the call returns {:error, :conflict, _} (single-use enforcement).
Card template pairs
List template pairs
{:ok, pairs, pagination} = AccessGrid.Console.list_card_template_pairs(
page: 1,
per_page: 25
)
Enum.each(pairs, fn pair ->
IO.puts("#{pair.name}: iOS=#{pair.ios_template.id}, Android=#{pair.android_template.id}")
end)Create a template pair
Pairs two existing card templates (one Apple, one Android) for cross-platform issuance. Both templates must be status: "ready" and use a compatible protocol combination (both SEOS, or Apple-DESFire + Android-SmartTap).
{:ok, pair} = AccessGrid.Console.create_card_template_pair(%{
name: "Cross-Platform Employee Badge",
apple_card_template_id: "tpl_apple_xyz",
google_card_template_id: "tpl_android_xyz"
})
IO.puts("Pair ID: #{pair.id}")Landing pages
List landing pages
{:ok, pages} = AccessGrid.Console.list_landing_pages()
Enum.each(pages, fn page -> IO.puts("#{page.id}: #{page.name} (#{page.kind})") end)Create a landing page
{:ok, page} = AccessGrid.Console.create_landing_page(%{
name: "Lobby Access",
kind: "universal",
additional_text: "Welcome — install your pass on your phone",
bg_color: "#1a1a1a",
allow_immediate_download: true,
logo: AccessGrid.Utils.base64_file!("path/to/logo.png")
})
IO.puts("Landing page ID: #{page.id}")
IO.puts("Logo URL: #{page.logo_url}")Update a landing page
kind is immutable after creation — passing a different value yields a {:error, :validation_failed, _}. Other fields can be updated freely.
{:ok, page} = AccessGrid.Console.update_landing_page("lp_ex_id_1", %{
name: "Lobby Access (renamed)",
password: "letmein",
is_2fa_enabled: true
})Credential profiles
List credential profiles
{:ok, profiles} = AccessGrid.Console.list_credential_profiles()
Enum.each(profiles, fn p -> IO.puts("#{p.id}: #{p.name} (aid=#{p.aid})") end)Create a credential profile
Each app has a fixed required key count: KEY-ID-main and KEY-ID-alt need 2 keys, ag_main needs 3. Passing the wrong number yields {:error, :validation_failed, _}.
{:ok, profile} = AccessGrid.Console.create_credential_profile(%{
name: "Office Reader",
app_name: "KEY-ID-main",
keys: [
%{value: "00112233445566778899AABBCCDDEEFF"},
%{value: "FFEEDDCCBBAA99887766554433221100", keys_diversified: true}
]
})
IO.puts("Profile ID: #{profile.id}")
IO.puts("AID: #{profile.aid}")
IO.inspect(profile.keys, label: "keys")
IO.inspect(profile.files, label: "files")Webhooks
List webhooks
{:ok, webhooks, pagination} = AccessGrid.Console.list_webhooks(page: 1, per_page: 50)
Enum.each(webhooks, fn wh ->
IO.puts("#{wh.id}: #{wh.name} (#{wh.auth_method}) → #{wh.url}")
end)Create a webhook
auth_method is either "bearer_token" (default) or "mtls". Sensitive fields appear on the create response only once:
- bearer_token:
private_keyis returned — store it immediately, it cannot be retrieved later. - mtls:
client_cert(PEM) andcert_expires_atare returned.
{:ok, webhook} = AccessGrid.Console.create_webhook(%{
name: "Production",
url: "https://example.com/hooks",
subscribed_events: ["ag.access_pass.issued", "ag.card_template.created"],
auth_method: "bearer_token"
})
IO.puts("Webhook ID: #{webhook.id}")
IO.puts("Private key (store now — not retrievable later): #{webhook.private_key}")Delete a webhook
Returns :ok (flat, not {:ok, _}) on success since Rails returns 204 No Content.
:ok = AccessGrid.Console.delete_webhook("webhook_id")HID orgs
List HID orgs
{:ok, orgs} = AccessGrid.Console.list_hid_orgs()
Enum.each(orgs, fn org -> IO.puts("#{org.id}: #{org.name} (status=#{org.status})") end)Create a HID org
Idempotent on the derived slug — if an org with the same slug already exists, Rails returns the existing record with 200 instead of creating a new one.
{:ok, org} = AccessGrid.Console.create_hid_org(%{
name: "Acme Corp",
full_address: "1 Acme Plaza, NY 10001",
phone: "+1-555-0100",
first_name: "Wile E.",
last_name: "Coyote"
})
IO.puts("HID org ID: #{org.id}")
IO.puts("Slug: #{org.slug}")Activate a HID org
Completes registration with the HID portal using the org's registered email and the customer's HID portal password.
{:ok, org} = AccessGrid.Console.activate_hid_org(%{
email: "admin@acme.com",
password: "hid-portal-password"
})
IO.puts("Status: #{org.status}")
Rails may return extra fields (already_completed: true if the org is already activated, job_queued: true if a registration job is in flight) — these aren't surfaced on the struct. Inspect org.status for the current state.
Ledger items
List ledger items
{:ok, items, pagination} = AccessGrid.Console.list_ledger_items(
page: 1,
per_page: 50,
start_date: "2026-01-01T00:00:00Z",
end_date: "2026-12-31T23:59:59Z"
)
Enum.each(items, fn item ->
IO.puts("#{item.created_at}: #{item.kind} $#{item.amount}")
if item.access_pass, do: IO.puts(" pass: #{item.access_pass.full_name}")
end)Utilities
Encode an image as base64
AccessGrid.Utils.base64_file!/1 reads a file from the local filesystem and returns its contents Base64-encoded as a string — suitable for any of the SDK's image-accepting params (background, logo, icon on create_template; logo on create_landing_page; employee_photo on AccessPasses.issue).
# Bang variant — raises File.Error if the path doesn't exist
b64 = AccessGrid.Utils.base64_file!("path/to/badge.png")
# Tuple variant — returns {:ok, encoded} or {:error, posix_reason}
case AccessGrid.Utils.base64_file("path/to/badge.png") do
{:ok, b64} -> # ...
{:error, :enoent} -> # file missing
end
The helper does not validate the file's contents — Rails enforces format and size limits (PNG/JPEG, 10MB max) and returns clear errors. No URL support: if you have an image at a URL, fetch it with your own HTTP client and Base.encode64/1 the bytes.
Error Handling
All functions return {:ok, result} (or {:ok, list, pagination} for paginated lists, or :ok for delete_webhook) on success, or {:error, reason, failure} on failure:
case AccessGrid.AccessPasses.get("card_id") do
{:ok, card} ->
IO.puts("Found card: #{card.full_name}")
{:error, :not_found, _failure} ->
IO.puts("Card not found")
{:error, :unauthorized, _failure} ->
IO.puts("Invalid credentials")
{:error, :validation_failed, failure} ->
IO.puts("Validation error: #{inspect(failure.body_decoded)}")
{:error, reason, _failure} ->
IO.puts("Request failed: #{reason}")
endError reasons include:
:unauthorized- Invalid credentials (401):forbidden- Access denied (403):not_found- Resource not found (404):conflict- Conflict with current resource state (409) — e.g.reveal_smart_tapretried with a pubkey that's already been used:validation_failed- Invalid parameters (422):rate_limited- Too many requests (429):timeout- Request timeout:server_error- Server error (5xx):request_failed- Other failures:missing_required- Local validation caught a missing/blank required field before any HTTP call. The third element is a non-empty list of atom field names (e.g.[:template_id, :access_pass_ex_id]) — not anHttpFailure. SeeAccessGrid.Params.
The third element (failure) is an AccessGrid.HttpFailure struct with additional context like status code and response body, except for :missing_required (see above — that variant carries a list of field-name atoms instead).
Testing
See the Testing Guide for detailed examples of how to mock AccessGrid in your tests.
Security
The SDK automatically handles:
- Request signing using HMAC-SHA256
- Secure payload encoding
- Authentication headers
- HTTPS communication
Never expose your api_secret in client-side code. Always use environment variables or a secure configuration management system.
Contributing
Bug reports and pull requests are welcome on GitHub.
Development
Requirements
- Elixir 1.17 or higher
- OTP 26 or higher
Note: A
.tool-versionsfile exists.asdfusers can install these requirements withasdf installfrom the project root.
Initial setup
After checking out the repo, run the doctor script to verify your environment:
bin/dev/doctorRun tests:
mix testRun all checks (format, credo, dialyzer):
bin/dev/auditLicense
The package is available as open source under the terms of the MIT License.