This guide issues a certificate to a respondent's Davi wallet when they submit your Google Form. The whole integration is a Google Apps Script attached to the form, with no server to run. You need to be an owner or manager of the organization.
The same approach works for any unattended automation where something outside Davi should cause a reward.
How the script gets a token
Redeeming a reward acts on behalf of an organization, so it needs an organization-scoped token. Nobody is signed in when a form is submitted:
client_credentialsdoes not work. A token issued to your application alone carries no organization context, and the endpoints that need one refuse it.- Token exchange needs a user token, and a user token needs somebody to sign in.
So you authorize once, by hand, store the refresh token, and do the rest on each submission:
one time you → sign in → authorization code → refresh token (stored)
per submit refresh token → user access token
user access token → exchange → org-scoped token
org-scoped token → redeem
1. Build the reward
The script issues an existing reward template. Create it in your organization's dashboard:
- Rewards → Add reward.
- Set the name, description, points and supply.
- Leave content storage on Platform, so Davi stores the content.
- Add item → Certificate, and fill in its title, description and image.
- Publish it. A draft or archived reward is refused at redemption.
The dashboard URL you end on contains both slugs the script needs:
/dashboard/orgs/<ORG_SLUG>/rewards/<REWARD_SLUG>
2. Ask the form for a Davi username
Davi resolves a recipient from a Davi identifier such as a username, a card or a wallet. An email address is not one of them. Add a short-answer question titled "Your Davi Username" and read the answer on submission.
A respondent can mistype their username. The script skips those responses instead of failing.
3. Add the script
In the form, open Extensions → Apps Script, replace the contents of
Code.gs with the script below, and save. The script is bound to the form, so it
can install its own submit trigger.
const OAUTH_SCOPES = 'org:read reward:read reward:redeem';
const USERNAME_QUESTION = 'Your Davi Username';
const PROP_REFRESH_TOKEN = 'DAVI_REFRESH_TOKEN';
const PROP_PKCE_VERIFIER = 'DAVI_PKCE_VERIFIER';
const PROP_ORG_UUID = 'DAVI_ORG_UUID';
const CACHE_USER_TOKEN = 'davi_user_access_token';
const CACHE_ORG_TOKEN = 'davi_org_access_token';
// ---------- Configuration ----------
function getConfig_() {
const props = PropertiesService.getScriptProperties();
const read = (key) => {
const value = props.getProperty(key);
if (!value) throw new Error('Script property ' + key + ' is not set.');
return value.replace(/\/+$/, '');
};
return {
apiBaseUrl: read('DAVI_API_BASE_URL'),
appBaseUrl: read('DAVI_APP_BASE_URL'),
clientId: read('DAVI_CLIENT_ID'),
clientSecret: read('DAVI_CLIENT_SECRET'),
orgSlug: read('DAVI_ORG_SLUG'),
rewardSlug: read('DAVI_REWARD_SLUG'),
};
}
function getRedirectUri() {
const uri = 'https://script.google.com/macros/d/' +
ScriptApp.getScriptId() + '/usercallback';
console.log(uri);
return uri;
}
// ---------- One-time authorization ----------
function authorize() {
const config = getConfig_();
const verifier = createCodeVerifier_();
PropertiesService.getScriptProperties().setProperty(PROP_PKCE_VERIFIER, verifier);
// Apps Script routes the redirect back to authCallback through this token.
const state = ScriptApp.newStateToken()
.withMethod('authCallback')
.withTimeout(600)
.createToken();
const url = config.appBaseUrl + '/oauth/authorize?' + encodeQuery_({
response_type: 'code',
client_id: config.clientId,
redirect_uri: getRedirectUri(),
scope: OAUTH_SCOPES,
state: state,
code_challenge: codeChallenge_(verifier),
code_challenge_method: 'S256',
});
console.log('Open this URL, signed in to Davi:\n' + url);
}
function authCallback(request) {
const params = request.parameter;
if (params.error) {
return HtmlService.createHtmlOutput(
'Davi refused the authorization: ' + params.error);
}
const config = getConfig_();
const props = PropertiesService.getScriptProperties();
const tokens = postToken_(config, {
grant_type: 'authorization_code',
code: params.code,
redirect_uri: getRedirectUri(),
code_verifier: props.getProperty(PROP_PKCE_VERIFIER),
});
props.setProperty(PROP_REFRESH_TOKEN, tokens.refresh_token);
props.deleteProperty(PROP_PKCE_VERIFIER);
cacheToken_(CACHE_USER_TOKEN, tokens);
CacheService.getScriptCache().remove(CACHE_ORG_TOKEN);
return HtmlService.createHtmlOutput('Connected to Davi. You can close this tab.');
}
// ---------- Tokens ----------
function getUserAccessToken_() {
const cache = CacheService.getScriptCache();
const cached = cache.get(CACHE_USER_TOKEN);
if (cached) return cached;
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
// Whoever held the lock may have refreshed already, retiring the
// refresh token this run read before waiting.
const refreshed = cache.get(CACHE_USER_TOKEN);
if (refreshed) return refreshed;
const props = PropertiesService.getScriptProperties();
const refreshToken = props.getProperty(PROP_REFRESH_TOKEN);
if (!refreshToken) {
throw new Error('Not connected to Davi. Run authorize() first.');
}
let tokens;
try {
tokens = postToken_(getConfig_(), {
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
} catch (err) {
throw new Error(err.message + ' (run authorize() to reconnect)');
}
props.setProperty(PROP_REFRESH_TOKEN, tokens.refresh_token);
return cacheToken_(CACHE_USER_TOKEN, tokens);
} finally {
lock.releaseLock();
}
}
function getOrgAccessToken_() {
const cache = CacheService.getScriptCache();
const cached = cache.get(CACHE_ORG_TOKEN);
if (cached) return cached;
const tokens = postToken_(getConfig_(), {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: getUserAccessToken_(),
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
resource: 'org:' + getOrganizationUuid_(),
});
return cacheToken_(CACHE_ORG_TOKEN, tokens);
}
function postToken_(config, form) {
const response = UrlFetchApp.fetch(config.apiBaseUrl + '/oauth2/token', {
method: 'post',
headers: {
Authorization: 'Basic ' +
Utilities.base64Encode(config.clientId + ':' + config.clientSecret),
},
payload: form,
muteHttpExceptions: true,
});
const body = parseJson_(response.getContentText());
if (response.getResponseCode() !== 200) {
const reason = body.error_description || body.error || body.message ||
response.getContentText();
throw new Error('Token request (' + form.grant_type + ') failed: ' + reason);
}
return body;
}
function cacheToken_(key, tokens) {
// Expire the cached copy a minute early; CacheService holds at most 6 hours.
const seconds = Math.min(Math.max((tokens.expires_in || 0) - 60, 1), 21600);
CacheService.getScriptCache().put(key, tokens.access_token, seconds);
return tokens.access_token;
}
// ---------- API ----------
function api_(method, path, token, body) {
const options = {
method: method,
headers: { Authorization: 'Bearer ' + token },
muteHttpExceptions: true,
};
if (body !== undefined) {
options.contentType = 'application/json';
options.payload = JSON.stringify(body);
}
const response = UrlFetchApp.fetch(getConfig_().apiBaseUrl + '/api/v1' + path, options);
const status = response.getResponseCode();
const data = parseJson_(response.getContentText());
if (status >= 400) {
const err = new Error(method.toUpperCase() + ' ' + path + ' failed with ' +
status + ': ' + (data.message || response.getContentText()));
err.statusCode = status;
err.apiCode = data.code;
throw err;
}
return data;
}
function getOrganizationUuid_() {
const props = PropertiesService.getScriptProperties();
const known = props.getProperty(PROP_ORG_UUID);
if (known) return known;
const org = api_('get', '/organizations/' + encodeURIComponent(getConfig_().orgSlug),
getUserAccessToken_());
props.setProperty(PROP_ORG_UUID, org.uuid);
return org.uuid;
}
function getRewardTemplate_() {
return api_('get', '/rewards/' + encodeURIComponent(getConfig_().rewardSlug),
getOrgAccessToken_());
}
function redeemCertificate_(username, idempotencyKey, metadata) {
return api_('post',
'/rewards/' + encodeURIComponent(getConfig_().rewardSlug) + '/redeem',
getOrgAccessToken_(),
{
identifier: { type: 'username', value: username },
scope: 'single-use',
idempotency_key: idempotencyKey,
metadata: metadata,
});
}
// ---------- Setup checks ----------
function checkConnection() {
getUserAccessToken_(); // refresh works
const orgUuid = getOrganizationUuid_(); // org resolves
getOrgAccessToken_(); // exchange works
const reward = getRewardTemplate_(); // reward is reachable
console.log('Organization %s, reward "%s" (%s).', orgUuid, reward.name, reward.status);
if (reward.status !== 'published') {
console.warn('A %s reward cannot be redeemed. Publish it first.', reward.status);
}
const content = (reward.content_config || {}).content_template || {};
const hasCertificate = (content.items || []).some((item) => item.type === 'certificate');
if (!hasCertificate) {
console.warn('The reward has no certificate item.');
}
}
function installTrigger() {
const form = FormApp.getActiveForm();
ScriptApp.getProjectTriggers()
.filter((trigger) => trigger.getHandlerFunction() === 'onFormSubmit')
.forEach((trigger) => ScriptApp.deleteTrigger(trigger));
ScriptApp.newTrigger('onFormSubmit').forForm(form).onFormSubmit().create();
}
// ---------- Form submission ----------
function onFormSubmit(e) {
const formResponse = e.response;
const responseId = formResponse.getId();
const username = findUsername_(formResponse);
if (!username) {
console.warn('Response %s has no Davi username. Skipped.', responseId);
return;
}
try {
const result = redeemCertificate_(username, idempotencyKeyFor_(responseId), {
source: 'google_forms',
form_response_id: responseId,
submitted_at: formResponse.getTimestamp().toISOString(),
});
console.log('Issued to @%s: %s', username, JSON.stringify(result));
} catch (err) {
if (err.apiCode === 'identifier_unresolved') {
console.warn('No Davi account for "@%s". Skipped.', username);
return;
}
throw err;
}
}
function findUsername_(formResponse) {
const answer = formResponse.getItemResponses().find(
(itemResponse) => itemResponse.getItem().getTitle() === USERNAME_QUESTION);
if (!answer) return null;
const username = String(answer.getResponse()).trim().replace(/^@/, '');
return username || null;
}
// ---------- Helpers ----------
function idempotencyKeyFor_(responseId) {
// The API accepts at most 64 characters; a response id can be longer.
return 'gforms-' + base64Url_(Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256, responseId, Utilities.Charset.UTF_8));
}
function createCodeVerifier_() {
return (Utilities.getUuid() + Utilities.getUuid()).replace(/-/g, '');
}
function codeChallenge_(verifier) {
return base64Url_(Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256, verifier, Utilities.Charset.US_ASCII));
}
function base64Url_(bytes) {
return Utilities.base64EncodeWebSafe(bytes).replace(/=+$/, '');
}
function encodeQuery_(params) {
return Object.keys(params)
.map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(params[key]))
.join('&');
}
function parseJson_(text) {
try {
return JSON.parse(text);
} catch (err) {
return {};
}
}
Run getRedirectUri from the editor and copy the URL it logs. Apps Script
asks you to grant the script access to the form, external requests and its own
properties the first time you run anything.
4. Register the OAuth application
- Settings → Developer → OAuth apps → Create app, as a confidential client.
- Register the redirect URI you copied in step 3.
- Grant it the scopes
org:read reward:read reward:redeem. - Reopen it and enable token exchange. It is a separate grant, and the create dialog does not offer it. Without it, every run fails at the exchange step.
5. Configure the script
Set these in Project Settings → Script properties. The script adds its own entries there as it runs (the refresh token and the organization's UUID).
| Property | Value |
|---|---|
DAVI_API_BASE_URL | https://api.davi.social |
DAVI_APP_BASE_URL | https://davi.social |
DAVI_CLIENT_ID | From the OAuth app |
DAVI_CLIENT_SECRET | Shown once on creation |
DAVI_ORG_SLUG | From the dashboard URL |
DAVI_REWARD_SLUG | From the dashboard URL |
The app host shows the consent screen; the API host issues the tokens.
6. Authorize once, by hand
Run authorize and open the URL it logs, in a browser signed in to Google as
the script's owner and to Davi as an owner or manager of the organization. After
you approve the consent screen, Davi redirects to the script, which stores a
refresh token and shows "Connected to Davi". No person is needed after this.
Run authorize again if the refresh token is ever revoked or expires unused.
The script's errors say so when a refresh is refused.
7. Check the connection
Run checkConnection. It walks every hop in order, so the first one that is
wrong is the one that throws: the refresh, the organization lookup, the
exchange, and reading the reward back. It warns about the two setups that issue
nothing: a reward that is not published, and one with no certificate item.
8. Install the trigger
Run installTrigger. It replaces any earlier submit trigger, so running it
twice does not issue twice.
Test by submitting the form, not by pressing Run on onFormSubmit. The
handler receives the form response as its argument, so running it from the
editor passes nothing and fails.
How the script behaves
Tokens. Every refresh returns a new refresh token and retires the one sent.
Presenting a retired one revokes the whole session. So getUserAccessToken_
caches the access token until a minute before it expires, takes the script lock
before refreshing, and checks the cache again once it holds the lock: whoever
held the lock may have just refreshed. The organization-scoped token is cached
the same way, so a submission that finds both cached makes one request instead
of three.
Repeats. The idempotency_key is derived from the form's response id. A
submission has one response id, so a re-run after a timeout is recognized as the
same redemption and does not issue a second certificate. The key is a hash
because the API accepts at most 64 characters.
Metadata. The response id and timestamp go in metadata. Nothing reads them
back automatically, but they record on the redemption why the certificate was
issued.
Failures. Apps Script emails the owner whenever a trigger throws. The handler
logs and skips a response with no username or an identifier_unresolved
username, and lets every other error throw.
Asynchronous rewards. With content storage on Platform the redemption
answers with a transaction_id. A reward on External storage answers with a
delivery_uuid instead, and the certificate exists only once your endpoint has
generated it.
Personalising the certificate
Read this if each certificate should name its holder. The steps above give everybody the same certificate.
Set the reward's content storage to External instead of Platform. On redemption Davi calls an endpoint you control; you build the certificate (with the recipient's name, their answers, your own serial) and return it, and Davi stores that copy immutably against the issuance. This needs an endpoint Davi can call.
Next
- Acting as an Organization: the token exchange in full.
- Rewards: templates, redemption and content storage.
- Errors and Rate Limits: what
identifier_unresolvedmeans.