tingee_ruby_sdk 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +337 -0
- data/docs/bank-auto-confirm-integration-guide.md +307 -0
- data/docs/tingee-api-reference.md +317 -0
- data/docs/tingee-vcb-personal-link.md +130 -0
- data/lib/tingee/client.rb +205 -0
- data/lib/tingee/configuration.rb +21 -0
- data/lib/tingee/error.rb +14 -0
- data/lib/tingee/signature.rb +44 -0
- data/lib/tingee/version.rb +3 -0
- data/lib/tingee/viet_qr.rb +125 -0
- data/lib/tingee.rb +30 -0
- data/lib/tingee_ruby_sdk.rb +2 -0
- metadata +59 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# Tingee BaaS — verified API reference
|
|
2
|
+
|
|
3
|
+
Official documentation: **https://developers.tingee.vn/docs/banking/**
|
|
4
|
+
|
|
5
|
+
Everything here was **observed against the live production API (2026-07-16)**, not
|
|
6
|
+
copied from the docs above. Claims marked `NOT OBSERVED` are unverified. This is
|
|
7
|
+
the contract `Tingee::Client` and `Tingee::Signature` are built against. Where
|
|
8
|
+
this file and the official docs disagree, this file records what the API
|
|
9
|
+
actually did; re-verify against the live API before trusting either.
|
|
10
|
+
|
|
11
|
+
Redaction: account numbers → last 4, no identity/CCCD, no mobile numbers, no
|
|
12
|
+
client_id/secret_token, no payer PII from memos.
|
|
13
|
+
|
|
14
|
+
## 0. Auth & signing (OBSERVED)
|
|
15
|
+
|
|
16
|
+
- Base URL (prod): `https://open-api.tingee.vn`
|
|
17
|
+
- Headers on every request: `x-client-id`, `x-request-timestamp`
|
|
18
|
+
(`yyyyMMddHHmmssSSS`, **UTC+7**), `x-signature`, `Content-Type: application/json`.
|
|
19
|
+
- Signature: `HMAC_SHA512(secret_token, x-request-timestamp + ":" + JSON.stringify(body))`, **hex** digest.
|
|
20
|
+
- **`body` is `payload || {}`** — a no-body GET signs the string `"{}"`, NOT empty.
|
|
21
|
+
Signing `""` returns `code 97 Invalid signature`. Confirmed against the official
|
|
22
|
+
`tingee-node` SDK (`src/signature/signer.ts`, `src/client/http.ts`:
|
|
23
|
+
`generateSignature(secretKey, timestamp, body || {})`) and a live `get-banks`
|
|
24
|
+
call. The wire request still omits the body on GET; only the SIGNED string is `"{}"`.
|
|
25
|
+
- Timestamp window: ±10 min of server clock (else error `90`).
|
|
26
|
+
|
|
27
|
+
Known-good triple from a live `get-banks` GET (the ENV-gated signature test asserts
|
|
28
|
+
this; the secret is not stored here):
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
timestamp: 20260716155928753
|
|
32
|
+
body: {}
|
|
33
|
+
signature: 93a3031c8e3a26ede4d4d684cad1f69a459acbfc24deb87a39ed918a6b70f909537105e8f7c491f59932ff9401c5499c8d92a02e6709a8310ddc947255ab4de4
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Ruby encoding note: `OpenSSL::HMAC.hexdigest` on a UTF-8 message warns
|
|
37
|
+
"UTF-8 string passed as BINARY" under json 3.0 — the gem `.b`-forces the signing
|
|
38
|
+
string to bytes. The digest of ASCII/UTF-8 bytes is unchanged either way.
|
|
39
|
+
|
|
40
|
+
## Response envelope
|
|
41
|
+
|
|
42
|
+
All endpoints return `{code, message, data}`; `code == "00"` is success and the
|
|
43
|
+
gem unwraps and returns `data`. **Exception: `get-banks` returns a bare JSON
|
|
44
|
+
array** — the array IS the success payload.
|
|
45
|
+
|
|
46
|
+
## 1. get-banks — supported banks (LIVE)
|
|
47
|
+
|
|
48
|
+
`GET /v1/get-banks`. Row shape:
|
|
49
|
+
`{code, name, bin, shortName, urlLogo, termsAndConditions:{url, urlVA}}`.
|
|
50
|
+
|
|
51
|
+
**Tingee supports 14 real banks (+ NEXTPAY mPOS).** Napas BIN → Tingee code:
|
|
52
|
+
|
|
53
|
+
| Bank | Napas BIN | Tingee code | Notes |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| Vietcombank | 970436 | VCB | |
|
|
56
|
+
| VietinBank | 970415 | CTG | |
|
|
57
|
+
| BIDV | 970418 | BIDV | |
|
|
58
|
+
| MB Bank | 970422 | MBB | |
|
|
59
|
+
| ACB | 970416 | ACB | needs register-notify (extra OTP round) |
|
|
60
|
+
| OCB | 970448 | OCB | |
|
|
61
|
+
| VPBank | 970432 | VPB | |
|
|
62
|
+
| Sacombank | 970403 | STB | |
|
|
63
|
+
| VIB | 970441 | VIB | |
|
|
64
|
+
| TPBank | 970423 | TPB | authorizeLink flow |
|
|
65
|
+
| MSB | 970426 | MSB | |
|
|
66
|
+
| PGBank | 970430 | PGB | |
|
|
67
|
+
| Shinhan | 970424 | SHINHAN | |
|
|
68
|
+
| Co-opBank | 970446 | COB | |
|
|
69
|
+
|
|
70
|
+
**Canonical supported code list** (leaked by a `create-bank-link-session` 400 on
|
|
71
|
+
`allowedBanks`): `OCB, BIDV, MBB, ACB, VPB, PGB, VIB, STB, CTG, VCB, AGRIBANK,
|
|
72
|
+
SHINHAN, COB, MSB, NEXTPAY, TPB`. Note `AGRIBANK` is accepted here but was NOT
|
|
73
|
+
returned by `get-banks` — Agribank is supported (via bank-link) despite the
|
|
74
|
+
get-banks gap.
|
|
75
|
+
|
|
76
|
+
**❌ NOT supported** (verified absent from both get-banks and allowedBanks):
|
|
77
|
+
**Techcombank (TCB)** — the one large bank genuinely unsupported — plus SHB,
|
|
78
|
+
HDBank, Eximbank, SeABank, NamABank, SCB, LPBank, VietABank, ABBANK, BacABank,
|
|
79
|
+
PVcomBank, NCB, VietCapitalBank, SaigonBank, BaoVietBank, VietBank, GPBank,
|
|
80
|
+
Oceanbank/MBV.
|
|
81
|
+
|
|
82
|
+
## 2. create-bank-link-session — hosted SDK flow (LIVE)
|
|
83
|
+
|
|
84
|
+
`POST /v1/create-bank-link-session` with an **empty body** → `code 00`,
|
|
85
|
+
`data` = SDK URL string.
|
|
86
|
+
|
|
87
|
+
- **No `merchantId` / sub-merchant provisioning needed** for the default merchant —
|
|
88
|
+
credentials resolve to a merchant directly. Pass `merchantId` only for a
|
|
89
|
+
sub-merchant.
|
|
90
|
+
- URL returned points at prod (`https://bank-link.tingee.vn?token=…&s=…`) when the
|
|
91
|
+
configured base is prod. Linking there links a real account.
|
|
92
|
+
- The token is base64 JSON: `{merchantId, type, timestamp, clientId}` — clientId is
|
|
93
|
+
the public id (not the secret).
|
|
94
|
+
- Optional params: `redirectUrl`, `allowedBanks` (array of Tingee codes),
|
|
95
|
+
`bankName`, `shopId`.
|
|
96
|
+
|
|
97
|
+
**Known Tingee bug (2026-07-16, escalated):** the hosted SDK JS crashes on some
|
|
98
|
+
banks with `TypeError: e.confirmId.startsWith is not a function` after the user
|
|
99
|
+
links. It's their frontend bug — the SDK internally calls `create-va` (it holds a
|
|
100
|
+
`confirmId`) and mishandles it. Fallback: the raw create-va chain below, which is
|
|
101
|
+
fully verified.
|
|
102
|
+
|
|
103
|
+
## 3. Manual link chain — create-va → confirm-va (LIVE, works end-to-end)
|
|
104
|
+
|
|
105
|
+
> **Redirect-authorize banks (VCB, TPB) use the same endpoint with a different
|
|
106
|
+
> shape:** add `appType:"baas"` + `redirectUrl` and create-va answers with an
|
|
107
|
+
> authorize link (`deepLink` for VCB, `authorizeLink` for TPB) instead of sending an
|
|
108
|
+
> OTP. There is no confirm-va step — the result arrives on your webhook as
|
|
109
|
+
> `status:"confirm-va-success"|"confirm-va-failed"` (§7). Pass your own `requestId`:
|
|
110
|
+
> it is echoed back on that webhook, and **TPB returns no `confirmId` at all**, so it
|
|
111
|
+
> is the only correlation key. One `Client#create_va` covers all shapes; unset params
|
|
112
|
+
> are omitted from the payload.
|
|
113
|
+
|
|
114
|
+
Bypasses the hosted SDK. Verified flow (Sacombank, real account):
|
|
115
|
+
|
|
116
|
+
1. `POST /v1/create-va`
|
|
117
|
+
`{accountType:"personal-account", bankBin, accountNumber, accountName,
|
|
118
|
+
identity (CCCD), mobile, isNotifyAccountNumber, webhookUrl}`
|
|
119
|
+
→ `data: {confirmId, otpMethod}`.
|
|
120
|
+
- **`mobile` must be domestic `0`-prefixed** (`09…`); `84…` →
|
|
121
|
+
`400 mobile must be a valid phone number`.
|
|
122
|
+
- STB returns `otpMethod:"SmartOTP"` (bank-app OTP, not SMS). No `authorizeLink`
|
|
123
|
+
(that's TPBank only).
|
|
124
|
+
- **`isNotifyAccountNumber: false` is the verified-working mode**: a real VietQR
|
|
125
|
+
transfer to the linked real account fired the payment webhook on a
|
|
126
|
+
notify=false link. notify=true ("watch the real account") MAY also work but
|
|
127
|
+
was never confirmed to fire on a plain transfer.
|
|
128
|
+
2. `POST /v1/confirm-va` `{bankBin, confirmId, otpNumber}`
|
|
129
|
+
→ `data: {bankName, accountType, accountNumber, vaAccountNumber, shopId}`.
|
|
130
|
+
- Bank-side OTP verification **can take minutes** — run in a background job with
|
|
131
|
+
a raised read timeout, not a web request.
|
|
132
|
+
- STB needs NO register-notify — confirm-va alone activates. **Only ACB needs
|
|
133
|
+
the extra step** (§4).
|
|
134
|
+
3. `get-va-paging` then shows the link:
|
|
135
|
+
`{bankName, bankBin, accountType, accountName, accountNumber:"xxxx…119",
|
|
136
|
+
vaAccountNumber:"TNG60716171228", status:"active", creationTime, shopId}`.
|
|
137
|
+
|
|
138
|
+
### The two account numbers (critical)
|
|
139
|
+
|
|
140
|
+
confirm-va returns BOTH:
|
|
141
|
+
|
|
142
|
+
- `accountNumber` — the **real** bank account. Money lands here; this is what a
|
|
143
|
+
customer transfers to and what a QR should show.
|
|
144
|
+
- `vaAccountNumber` — `TNG` + timestamp digits, a **Tingee-internal routing
|
|
145
|
+
handle**, NOT a transferable account. Store it as your unique routing key for
|
|
146
|
+
webhooks.
|
|
147
|
+
|
|
148
|
+
Also store `bankBin` and `shopId` from the response — `delete-va` needs the BIN.
|
|
149
|
+
|
|
150
|
+
## 4. register-notify — ACB only (LIVE contract)
|
|
151
|
+
|
|
152
|
+
`POST /v1/register-notify` `{vaAccountNumber, bankBin}` → `data: {confirmId}` —
|
|
153
|
+
run once right after confirm-va succeeds, then
|
|
154
|
+
`POST /v1/confirm-register-notify` `{bankBin, confirmId, otpNumber}` with the
|
|
155
|
+
second OTP.
|
|
156
|
+
|
|
157
|
+
## 5. get-va-paging (LIVE)
|
|
158
|
+
|
|
159
|
+
`POST /v1/get-va-paging` (optional `{merchantId}`) → `data: {totalCount, items}`.
|
|
160
|
+
|
|
161
|
+
## 6. Unlink — delete-va → confirm-delete-va (LIVE)
|
|
162
|
+
|
|
163
|
+
Tingee is inconsistent about *where* the params ride, but both key the bank by
|
|
164
|
+
**BIN** — verified live:
|
|
165
|
+
|
|
166
|
+
- `POST /v1/delete-va` — params ride the **QUERY STRING** (not the JSON body):
|
|
167
|
+
`?bankBin=970403&vaAccountNumber=TNG…`. The bodyless-signing convention still
|
|
168
|
+
applies (the signed string is `"{}"`). → `data: {confirmId}`.
|
|
169
|
+
- `POST /v1/confirm-delete-va` — params ride the **BODY**:
|
|
170
|
+
`{bankBin, confirmId, otpNumber}`.
|
|
171
|
+
|
|
172
|
+
**Trap (2026-07-17):** a `?bankName=STB` variant of delete-va *appears* to work — it
|
|
173
|
+
returns a `confirmId` and the bank really does send an OTP — but the session it opens
|
|
174
|
+
is unconfirmable: confirm-delete-va then fails with
|
|
175
|
+
`"Lỗi hệ thống phương thức xác thực"`. The failure surfaces one step later than the
|
|
176
|
+
mistake, which is what makes it costly to diagnose. Use the BIN on both calls.
|
|
177
|
+
|
|
178
|
+
Response shape varies by bank: OTP banks return `{confirmId}`, TPB returns an
|
|
179
|
+
`authorizeLink` (owner approves at the bank; a `delete-va-success` webhook finishes
|
|
180
|
+
it), and VCB returns `{}` — already detached, nothing to confirm.
|
|
181
|
+
|
|
182
|
+
Unlinking is also how per-webhook billing stops for an account (see §9).
|
|
183
|
+
|
|
184
|
+
## 6b. transaction/get-paging — transaction history (DOC, not yet live-verified)
|
|
185
|
+
|
|
186
|
+
`POST /v1/transaction/get-paging` → `data: {totalCount, items}`. Body:
|
|
187
|
+
|
|
188
|
+
- `startTime`, `endTime` — **required**, format `"yyyyMMddHHmmss"` (UTC+7). Each
|
|
189
|
+
request may span at most a **10-day** window (Tingee errors past that).
|
|
190
|
+
- Optional: `filter` (keyword — tx code/content), `skipCount`, `maxResultCount`,
|
|
191
|
+
`merchantId` (required for a Master Merchant), `shopIds[]`, `vaAccountNumbers[]`,
|
|
192
|
+
`bankBin`.
|
|
193
|
+
|
|
194
|
+
Item shape (per the docs): `{transactionId, accountNumber, amount, type
|
|
195
|
+
(CREDIT/DEBIT), bankBin, bankName, transactionTime, description}`.
|
|
196
|
+
|
|
197
|
+
## 7. Webhooks
|
|
198
|
+
|
|
199
|
+
### Payment callback (LIVE, real captured payload)
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{"clientId":"…","transactionCode":"<acct>/FT26197KVBJB","amount":2000,
|
|
203
|
+
"content":"hello FT26197646813469","bank":"STB","bankBin":"970403",
|
|
204
|
+
"accountNumber":"<real acct>","vaAccountNumber":"TNG60716173559",
|
|
205
|
+
"transactionDate":"20260716174026","additionalData":[]}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Field semantics (all confirmed real):
|
|
209
|
+
|
|
210
|
+
- idempotency key → `transactionCode` (`<accountNumber>/FT<ref>`, unique per txn)
|
|
211
|
+
- amount → `amount` — **integer**, no decimals observed
|
|
212
|
+
- memo → `content` (payer text; the bank may append its FT ref)
|
|
213
|
+
- routing → `vaAccountNumber` (unique per link) or `accountNumber` (real);
|
|
214
|
+
`bankBin` IS present
|
|
215
|
+
- `transactionDate` — `yyyyMMddHHmmss`
|
|
216
|
+
- `additionalData: []` — empty for a plain transfer; a billId-bound dynamic QR may
|
|
217
|
+
populate it
|
|
218
|
+
|
|
219
|
+
**A plain bank transfer (any VietQR / manual transfer into the real account) fires
|
|
220
|
+
the webhook** — no Tingee-minted QR required. Verified with a real transfer.
|
|
221
|
+
|
|
222
|
+
### Bank-link result callback (redirect-authorize banks)
|
|
223
|
+
|
|
224
|
+
Redirect-authorize banks (VCB, TPB) have no confirm-va step — the link's outcome
|
|
225
|
+
arrives here instead, on the same endpoint as payments:
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
{"requestId":"<the requestId YOU sent to create-va>","status":"confirm-va-success",
|
|
229
|
+
"vaAccountNumber":"TNG…","accountNumber":"<real acct>","accountName":"NGUYEN VAN A"}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- `status` — `confirm-va-success` | `confirm-va-failed` | `delete-va-success`.
|
|
233
|
+
- **Distinguish it from a payment by SHAPE**: a link result has `status` and NO
|
|
234
|
+
`transactionCode`; a payment has `transactionCode` and no `status`. The two are
|
|
235
|
+
disjoint, so branch on that rather than guessing from other fields.
|
|
236
|
+
- Correlate on `requestId` — it echoes the one you sent to create-va. VCB echoes it
|
|
237
|
+
as `confirmId` in the create-va response too; **TPB returns no confirmId at all**,
|
|
238
|
+
so your own `requestId` is the only key that works for both.
|
|
239
|
+
- Unlike confirm-va, this payload DOES echo `accountName` — prefer it over whatever
|
|
240
|
+
the owner typed.
|
|
241
|
+
- `delete-va-success` is best matched on `vaAccountNumber` (authoritative for "which
|
|
242
|
+
account just detached"); its `requestId` echo is unverified.
|
|
243
|
+
|
|
244
|
+
### Signature verification (LIVE — raw bytes, resolved 2026-07-16)
|
|
245
|
+
|
|
246
|
+
Tingee signs the **RAW body bytes exactly as sent**:
|
|
247
|
+
`x-signature = HMAC_SHA512(secret, x-request-timestamp + ":" + raw_body)`.
|
|
248
|
+
|
|
249
|
+
A genuine payment callback's signature was reproduced from the raw body verbatim.
|
|
250
|
+
Do NOT re-parse/re-serialize — hash raw. (The official `tingee-node` SDK's
|
|
251
|
+
`verifyWebhookSignature` re-serializes via `JSON.stringify(JSON.parse(body))`;
|
|
252
|
+
against the real webhook, raw bytes are what verified. Raw is also immune to
|
|
253
|
+
Ruby-vs-JS number formatting: a re-serialized whole-number float would render
|
|
254
|
+
`250000.0` in Ruby vs `250000` in JS and break a valid signature.)
|
|
255
|
+
Rails: pass `request.raw_post` verbatim to `Tingee::Signature.verify`.
|
|
256
|
+
|
|
257
|
+
### Connection-test ping (LIVE)
|
|
258
|
+
|
|
259
|
+
Tingee's dashboard "test webhook" sends
|
|
260
|
+
`{"event":"ping","message":"Tingee Webhook Connection Test","timestamp":<ms>}`
|
|
261
|
+
with **NO `x-signature` / `x-request-timestamp` headers — the ping is UNSIGNED**.
|
|
262
|
+
Your endpoint must short-circuit on `event == "ping"` (or "only verify when the
|
|
263
|
+
signature header is present") and 200-ack, else the dashboard's connection test
|
|
264
|
+
shows failure.
|
|
265
|
+
|
|
266
|
+
### Ack & retry contract
|
|
267
|
+
|
|
268
|
+
- Success ack: `{"code":"00","message":"Success"}` at HTTP 200 — Tingee stops
|
|
269
|
+
retrying on it. Always ack replays too (idempotency on `transactionCode`).
|
|
270
|
+
- Retry interval/count on failure: Tingee's docs conflict (1 min vs 5 min) —
|
|
271
|
+
`NOT OBSERVED` precisely.
|
|
272
|
+
- Whether `"02"` (already-processed) is honored for replays: `NOT OBSERVED`.
|
|
273
|
+
|
|
274
|
+
### Registering the webhook
|
|
275
|
+
|
|
276
|
+
In the Tingee dashboard, register your webhook URL with auth =
|
|
277
|
+
**API Credentials** (HMAC) — not the static API-key option — so callbacks carry
|
|
278
|
+
`x-signature`/`x-request-timestamp`.
|
|
279
|
+
|
|
280
|
+
## 8. Error codes
|
|
281
|
+
|
|
282
|
+
| Code | Meaning |
|
|
283
|
+
|---|---|
|
|
284
|
+
| `00` | Success |
|
|
285
|
+
| `90` | Timestamp outside the ±10 min window |
|
|
286
|
+
| `91` | Timeout |
|
|
287
|
+
| `97` | Invalid signature (including the signed-`""`-instead-of-`"{}"` mistake) |
|
|
288
|
+
| `1001`–`1076` | Business errors (undocumented series, codes kept raw) |
|
|
289
|
+
|
|
290
|
+
The gem raises `Tingee::Error` with the raw code preserved; transport failures are
|
|
291
|
+
normalized to code `"NETWORK"`, non-JSON gateway/WAF pages to `"HTTP_<status>"`.
|
|
292
|
+
|
|
293
|
+
## 9. Operational notes
|
|
294
|
+
|
|
295
|
+
- **Billing is per delivered webhook** (check Tingee's current pricing).
|
|
296
|
+
Delivered ≠ matched — unroutable webhooks still bill. Whether retries
|
|
297
|
+
bill: `NOT OBSERVED` (ask support).
|
|
298
|
+
- Disabling your own feature flags does NOT stop the meter — **only unlinking
|
|
299
|
+
(`delete-va` chain) stops webhook billing** for an account.
|
|
300
|
+
- Post-unlink webhook behavior (do they fully stop?): `NOT OBSERVED`.
|
|
301
|
+
- `vaAccountNumber` uniqueness scope (global vs bank-scoped): `NOT OBSERVED`.
|
|
302
|
+
- Outgoing-transfer callbacks (does a debit fire a webhook too?): `NOT OBSERVED` —
|
|
303
|
+
determines whether the per-webhook cost covers only customer payments or all
|
|
304
|
+
balance changes.
|
|
305
|
+
|
|
306
|
+
## 10. Known Tingee bugs (escalated 2026-07-16, off this gem's critical path)
|
|
307
|
+
|
|
308
|
+
- `/v1/generate-dynamic-qr` returns
|
|
309
|
+
`500 "Field 'accountNumber' doesn't have a default value"` for a valid documented
|
|
310
|
+
request (their dashboard mints the same QR fine). Not wrapped by this gem —
|
|
311
|
+
plain transfers fire the webhook, so dynamic QRs aren't needed for payment
|
|
312
|
+
confirmation. Unnecessary anyway: `Tingee::VietQR.payload` mints the transfer QR
|
|
313
|
+
locally, and a plain transfer into the real account fires the webhook whatever
|
|
314
|
+
minted the QR.
|
|
315
|
+
- Hosted bank-link JS SDK crashes
|
|
316
|
+
`TypeError: e.confirmId.startsWith is not a function` (§2). Workaround: the raw
|
|
317
|
+
create-va chain (§3).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# 🛠️ Tài liệu hướng dẫn tích hợp API — Liên kết tài khoản VCB Cá nhân
|
|
2
|
+
|
|
3
|
+
> Nguồn: `Tài_liệu_Liên_kết_VCB_Cá_nhân.docx` (Tingee by Heno). Chuyển sang Markdown, giữ nguyên nội dung gốc.
|
|
4
|
+
>
|
|
5
|
+
> **Lưu ý luồng:** VCB cá nhân dùng **deepLink** mở app VCB Digibank để khách xác nhận — KHÔNG có bước `confirm-va` bằng OTP như các ngân hàng khác. Kết quả liên kết trả về **bất đồng bộ qua webhook** (`confirm-va-success` / `confirm-va-failed`).
|
|
6
|
+
|
|
7
|
+
## I. Tổng quan
|
|
8
|
+
|
|
9
|
+
Tài liệu này hướng dẫn cách tích hợp API của hệ thống Tingee để thực hiện:
|
|
10
|
+
|
|
11
|
+
- Liên kết tài khoản VCB cá nhân
|
|
12
|
+
- Nhận webhook khi có kết quả
|
|
13
|
+
|
|
14
|
+
## II. Hướng dẫn chi tiết
|
|
15
|
+
|
|
16
|
+
### Bước 1: Đăng ký tài khoản Tingee
|
|
17
|
+
|
|
18
|
+
Truy cập: <https://app.tingee.vn/>
|
|
19
|
+
|
|
20
|
+
### Bước 2: Lấy thông tin ứng dụng
|
|
21
|
+
|
|
22
|
+
Truy cập: **Trang chủ Tingee → Avatar → Developers**
|
|
23
|
+
|
|
24
|
+
Tại đây bạn sẽ lấy được:
|
|
25
|
+
|
|
26
|
+
- `x-client-id`
|
|
27
|
+
- `Secret token`
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
## III. API liên kết tài khoản ngân hàng VCB
|
|
32
|
+
|
|
33
|
+
**Endpoint:** `POST https://open-api.tingee.vn/v1/create-va`
|
|
34
|
+
|
|
35
|
+
### Headers
|
|
36
|
+
|
|
37
|
+
| Tên | Kiểu | Mô tả |
|
|
38
|
+
|-----|------|-------|
|
|
39
|
+
| `x-client-id` | string | Định danh ứng dụng (lấy tại mục Developers) |
|
|
40
|
+
| `x-request-timestamp` | string | Dấu thời gian theo định dạng `yyyyMMddHHmmssSSS` |
|
|
41
|
+
| `x-signature` | string | Chuỗi hash HMAC SHA512: `{timestamp}:{requestBodyString}` với Secret token làm key |
|
|
42
|
+
|
|
43
|
+
### Request Body
|
|
44
|
+
|
|
45
|
+
| Tên | Kiểu | Mô tả |
|
|
46
|
+
|-----|------|-------|
|
|
47
|
+
| `requestId` | UUID | ID request liên kết tài khoản |
|
|
48
|
+
| `bankName` / `bankBin` | string | Mã code / Mã bin VCB |
|
|
49
|
+
| `mobile` | string | Số điện thoại KH |
|
|
50
|
+
| `merchantId` | number | ID merchant Tingee |
|
|
51
|
+
| `shopId` | number | ID shop muốn liên kết VA (bỏ qua nếu muốn tạo shop mới trên Tingee) |
|
|
52
|
+
| `merchantName` | string | Tên shop của merchant |
|
|
53
|
+
| `merchantAddress` | string | Địa chỉ shop |
|
|
54
|
+
| `accountNumber` | string | Số tài khoản |
|
|
55
|
+
| `accountType` | string | Truyền `personal-account` |
|
|
56
|
+
| `vaPrefix` | string | Mã đầu VA (dùng cho luồng quét QR trên loa để liên kết) |
|
|
57
|
+
| `vaSuffix` | string | Chuỗi VA (dùng cho luồng quét QR trên loa để liên kết) |
|
|
58
|
+
| `appType` | string | Mặc định để `baas` |
|
|
59
|
+
| `redirectUrl` | string | URL để redirect về app FinOne |
|
|
60
|
+
| `webhookUrl` | string | URL nhận webhook kết quả |
|
|
61
|
+
|
|
62
|
+
### Response
|
|
63
|
+
|
|
64
|
+
| Tên | Kiểu | Mô tả |
|
|
65
|
+
|-----|------|-------|
|
|
66
|
+
| `code` | string | Mã lỗi |
|
|
67
|
+
| `message` | string | Thông tin lỗi |
|
|
68
|
+
| `data` | object | |
|
|
69
|
+
| `data.confirmId` | string | ID confirm |
|
|
70
|
+
| `data.deepLink` | string | Deeplink mở app VCB |
|
|
71
|
+
|
|
72
|
+
### Sample
|
|
73
|
+
|
|
74
|
+
**Request (curl):**
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
curl -X 'POST' \
|
|
78
|
+
'http://open-api.tingee.vn/v1/create-va' \
|
|
79
|
+
-H 'accept: */*' \
|
|
80
|
+
-H 'x-signature: 222da13b161cd6a15f337a4c35a22cf3a0ff375bab494a4ae302d07c27687aa281b7899d7a31eeceb9caad7eb0dab506f880b590c01f49e750329fee54213ab5' \
|
|
81
|
+
-H 'x-request-timestamp: 20250828140611111' \
|
|
82
|
+
-H 'x-client-id: 3021ab73e90e72e3a0f01cbfd8f6d604' \
|
|
83
|
+
-H 'Content-Type: application/json' \
|
|
84
|
+
-d '{
|
|
85
|
+
"requestId": "000000c4-62bd-4e50-a213-b51e3772074e",
|
|
86
|
+
"bankName": "VCB",
|
|
87
|
+
"mobile": "0987665555",
|
|
88
|
+
"merchantId": 140998,
|
|
89
|
+
"merchantName": "Cửa hàng số 1",
|
|
90
|
+
"merchantAddress": "Hà Nội",
|
|
91
|
+
"accountNumber": "0912323232",
|
|
92
|
+
"accountType": "personal-account",
|
|
93
|
+
"appType": "baas",
|
|
94
|
+
"redirectUrl": "finonemerchant://"
|
|
95
|
+
}'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Response:**
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"code": "00",
|
|
103
|
+
"message": "Success",
|
|
104
|
+
"data": [
|
|
105
|
+
{
|
|
106
|
+
"confirmId": "000000c4-62bd-4e50-a213-b51e3772074e",
|
|
107
|
+
"deepLink": "vcbpartner://linkPaymentEvent?token=eyJhbGciOiJSUzI1NiJ9...."
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## IV. Nhận Webhook thông báo kết quả
|
|
114
|
+
|
|
115
|
+
- Khi khách hàng xác nhận liên kết xong trên app VCB Digibank, VCB sẽ gửi webhook kết quả về cho Tingee. Từ đó Tingee xử lý dữ liệu rồi gửi webhook lại cho FinOne qua **Webhook URL** mà đối tác đã gửi qua Tingee ở API liên kết tài khoản.
|
|
116
|
+
|
|
117
|
+
### Dữ liệu Webhook
|
|
118
|
+
|
|
119
|
+
| Tên | Kiểu | Mô tả |
|
|
120
|
+
|-----|------|-------|
|
|
121
|
+
| `requestId` | string | ID request khi gọi API liên kết |
|
|
122
|
+
| `merchantId` | number | ID Merchant |
|
|
123
|
+
| `shopId` | number | ID shop |
|
|
124
|
+
| `bankName` | string | Mã code VCB |
|
|
125
|
+
| `bankBin` | string | Mã bin VCB |
|
|
126
|
+
| `accountNumber` | string | Số tài khoản |
|
|
127
|
+
| `accountName` | string | Tên chủ tài khoản |
|
|
128
|
+
| `vaAccountNumber` | string | Số tài khoản ảo |
|
|
129
|
+
| `isNotifyAccountNumber` | boolean | `false`: Liên kết và tạo TK ảo |
|
|
130
|
+
| `status` | string | Trạng thái liên kết — `"confirm-va-success"`: Thành công / `"confirm-va-failed"`: Thất bại |
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Tingee
|
|
6
|
+
# HTTP + endpoint methods. Only live-verified endpoints are wrapped — no
|
|
7
|
+
# speculative "complete SDK". Responses use a uniform {code, message, data}
|
|
8
|
+
# envelope EXCEPT get-banks, which returns a bare array (verified live).
|
|
9
|
+
class Client
|
|
10
|
+
# read_timeout: web requests keep the 90s default (stay under your proxy's
|
|
11
|
+
# response timeout); background jobs should pass a larger value — bank-side
|
|
12
|
+
# OTP verification on confirm_va can take minutes.
|
|
13
|
+
def initialize(config = Tingee.config, read_timeout: 90)
|
|
14
|
+
@config = config
|
|
15
|
+
@read_timeout = read_timeout
|
|
16
|
+
@config.validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# GET /v1/get-banks — returns the bare bank array (the supported-bank/BIN map).
|
|
20
|
+
def get_banks
|
|
21
|
+
get("/v1/get-banks")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# POST /v1/create-bank-link-session — returns the SDK URL string (data). No
|
|
25
|
+
# merchant_id is needed for the default merchant; pass one only for a sub-merchant.
|
|
26
|
+
def create_bank_link_session(merchant_id: nil, redirect_url: nil, allowed_banks: nil, bank_name: nil, shop_id: nil)
|
|
27
|
+
payload = { type: "bank-link" }
|
|
28
|
+
payload[:merchantId] = merchant_id if merchant_id
|
|
29
|
+
payload[:redirectUrl] = redirect_url if redirect_url
|
|
30
|
+
payload[:allowedBanks] = Array(allowed_banks) if allowed_banks
|
|
31
|
+
payload[:bankName] = bank_name if bank_name
|
|
32
|
+
payload[:shopId] = shop_id if shop_id
|
|
33
|
+
post("/v1/create-bank-link-session", payload)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# POST /v1/get-va-paging — returns {totalCount, items} of linked virtual accounts.
|
|
37
|
+
def get_va_paging(merchant_id: nil)
|
|
38
|
+
payload = {}
|
|
39
|
+
payload[:merchantId] = merchant_id if merchant_id
|
|
40
|
+
post("/v1/get-va-paging", payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# POST /v1/create-va — starts a bank link (the raw API chain, usable when Tingee's
|
|
44
|
+
# hosted JS SDK is unavailable or broken — docs/tingee-api-reference.md §create-va,
|
|
45
|
+
# live-verified). ONE method serves all three bank shapes; which one you get is
|
|
46
|
+
# decided by the bank, not by a different endpoint:
|
|
47
|
+
#
|
|
48
|
+
# OTP banks (STB, ACB, MBB, …) — pass account_number/account_name/identity/mobile.
|
|
49
|
+
# The bank sends/pushes an OTP to `mobile`. Returns {confirmId, otpMethod};
|
|
50
|
+
# finish with #confirm_va.
|
|
51
|
+
# Redirect-authorize banks (VCB) — additionally pass app_type: "baas" +
|
|
52
|
+
# redirect_url + request_id. Returns an authorizeLink/deepLink instead of
|
|
53
|
+
# sending an OTP; the owner confirms in the bank's app and the result arrives
|
|
54
|
+
# asynchronously on webhook_url as {status: "confirm-va-success"|"confirm-va-failed"}.
|
|
55
|
+
# There is NO confirm_va step for these.
|
|
56
|
+
# No-account-field banks (TPB, doc-sourced) — pass neither account nor identity
|
|
57
|
+
# fields; the owner picks the account on the bank's own web. TPB returns NO
|
|
58
|
+
# confirmId at all, so the request_id YOU send is the only key that can
|
|
59
|
+
# correlate the settle webhook back to your pending request. Always pass and
|
|
60
|
+
# STORE your own request_id for any redirect-authorize flow.
|
|
61
|
+
#
|
|
62
|
+
# Every optional field is omitted from the payload when nil, so a bank only ever
|
|
63
|
+
# receives the params its contract actually defines (VCB tolerates and overrides
|
|
64
|
+
# what it doesn't use — verified live; TPB's contract has none of them).
|
|
65
|
+
#
|
|
66
|
+
# is_notify_account_number: FALSE is the DEFAULT because it is the mode proved
|
|
67
|
+
# end-to-end — a real VietQR transfer to the linked real account fired the webhook
|
|
68
|
+
# on a notify=false link (2026-07-16). notify=true is documented as "watch the real
|
|
69
|
+
# account" and MAY also work, but was never confirmed to fire on a plain transfer;
|
|
70
|
+
# do not switch the default to true without a real-transfer test on a true link.
|
|
71
|
+
def create_va(bank_bin:, webhook_url:, account_number: nil, account_name: nil,
|
|
72
|
+
identity: nil, mobile: nil, account_type: "personal-account",
|
|
73
|
+
is_notify_account_number: false, app_type: nil, redirect_url: nil,
|
|
74
|
+
request_id: nil, merchant_id: nil, merchant_name: nil,
|
|
75
|
+
merchant_address: nil, shop_id: nil, va_prefix: nil, va_suffix: nil)
|
|
76
|
+
payload = {
|
|
77
|
+
accountType: account_type, bankBin: bank_bin,
|
|
78
|
+
isNotifyAccountNumber: is_notify_account_number,
|
|
79
|
+
webhookUrl: webhook_url
|
|
80
|
+
}
|
|
81
|
+
payload[:accountNumber] = account_number if account_number
|
|
82
|
+
payload[:accountName] = account_name if account_name
|
|
83
|
+
payload[:identity] = identity if identity
|
|
84
|
+
payload[:mobile] = mobile if mobile
|
|
85
|
+
payload[:appType] = app_type if app_type
|
|
86
|
+
payload[:redirectUrl] = redirect_url if redirect_url
|
|
87
|
+
payload[:requestId] = request_id if request_id
|
|
88
|
+
payload[:merchantId] = merchant_id if merchant_id
|
|
89
|
+
payload[:merchantName] = merchant_name if merchant_name
|
|
90
|
+
payload[:merchantAddress] = merchant_address if merchant_address
|
|
91
|
+
payload[:vaPrefix] = va_prefix if va_prefix
|
|
92
|
+
payload[:vaSuffix] = va_suffix if va_suffix
|
|
93
|
+
# One shop per project; an explicit arg wins over the configured default.
|
|
94
|
+
shop_id ||= @config.shop_id
|
|
95
|
+
payload[:shopId] = shop_id if shop_id
|
|
96
|
+
post("/v1/create-va", payload)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# POST /v1/confirm-va — finishes the link with the bank's OTP. Returns
|
|
100
|
+
# {bankName, accountType, accountNumber (real), vaAccountNumber (routing key), shopId}.
|
|
101
|
+
def confirm_va(bank_bin:, confirm_id:, otp_number:)
|
|
102
|
+
post("/v1/confirm-va", { bankBin: bank_bin, confirmId: confirm_id, otpNumber: otp_number })
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# POST /v1/register-notify — ACB only, run once right after confirm_va succeeds.
|
|
106
|
+
# Returns {confirmId} for a second OTP round (see #confirm_register_notify).
|
|
107
|
+
def register_notify(bank_bin:, va_account_number:)
|
|
108
|
+
post("/v1/register-notify", { vaAccountNumber: va_account_number, bankBin: bank_bin })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def confirm_register_notify(bank_bin:, confirm_id:, otp_number:)
|
|
112
|
+
post("/v1/confirm-register-notify", { bankBin: bank_bin, confirmId: confirm_id, otpNumber: otp_number })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# POST /v1/delete-va — starts an unlink. Params ride the QUERY STRING (not the
|
|
116
|
+
# JSON body); the bank is identified by `bankBin`, per the documented contract.
|
|
117
|
+
#
|
|
118
|
+
# A `bankName` variant (Tingee's short bank CODE, e.g. "STB") also returns a
|
|
119
|
+
# confirmId AND triggers the bank's OTP, so it looks like it worked — but the
|
|
120
|
+
# session it creates cannot be confirmed: #confirm_delete_va then 400s with
|
|
121
|
+
# "Lỗi hệ thống phương thức xác thực" (seen live 2026-07-17). Do not reintroduce
|
|
122
|
+
# it. Unlink is the only way to stop Tingee's per-webhook billing, so an unlink
|
|
123
|
+
# that silently cannot complete is expensive.
|
|
124
|
+
#
|
|
125
|
+
# Returns {confirmId} (OTP banks). Bank-shape variations — TPB answers with an
|
|
126
|
+
# authorizeLink, VCB returns {} because it detaches immediately — are the caller's
|
|
127
|
+
# to branch on; see docs/tingee-api-reference.md §6.
|
|
128
|
+
def delete_va(bank_bin:, va_account_number:)
|
|
129
|
+
request(:post, "/v1/delete-va", query: { bankBin: bank_bin, vaAccountNumber: va_account_number })
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# POST /v1/confirm-delete-va — finishes the unlink with the bank's OTP. Params
|
|
133
|
+
# ride the BODY this time (unlike delete_va's query string above), and the bank
|
|
134
|
+
# is identified by bankBin here — bankName gets ignored and Tingee then fails
|
|
135
|
+
# with "Lỗi hệ thống phương thức xác thực" (seen live 2026-07-17).
|
|
136
|
+
def confirm_delete_va(bank_bin:, confirm_id:, otp_number:)
|
|
137
|
+
post("/v1/confirm-delete-va", { bankBin: bank_bin, confirmId: confirm_id, otpNumber: otp_number })
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# POST /v1/transaction/get-paging — transaction history. `start_time`/`end_time`
|
|
141
|
+
# are required, format "yyyyMMddHHmmss" (UTC+7); Tingee caps each query at a
|
|
142
|
+
# 10-day window (over that it returns an error — not enforced here). Optional
|
|
143
|
+
# params are only sent when given. Returns {totalCount, items}.
|
|
144
|
+
def get_transactions(start_time:, end_time:, filter: nil, skip_count: nil, max_result_count: nil,
|
|
145
|
+
merchant_id: nil, shop_ids: nil, va_account_numbers: nil, bank_bin: nil)
|
|
146
|
+
payload = { startTime: start_time, endTime: end_time }
|
|
147
|
+
payload[:filter] = filter if filter
|
|
148
|
+
payload[:skipCount] = skip_count if skip_count
|
|
149
|
+
payload[:maxResultCount] = max_result_count if max_result_count
|
|
150
|
+
payload[:merchantId] = merchant_id if merchant_id
|
|
151
|
+
payload[:shopIds] = Array(shop_ids) if shop_ids
|
|
152
|
+
payload[:vaAccountNumbers] = Array(va_account_numbers) if va_account_numbers
|
|
153
|
+
payload[:bankBin] = bank_bin if bank_bin
|
|
154
|
+
post("/v1/transaction/get-paging", payload)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def get(path) = request(:get, path)
|
|
160
|
+
def post(path, body) = request(:post, path, body)
|
|
161
|
+
|
|
162
|
+
def request(method, path, payload = nil, query: nil)
|
|
163
|
+
signed_body = JSON.generate(payload || {}) # bodyless request signs "{}", not ""
|
|
164
|
+
ts = Signature.timestamp
|
|
165
|
+
sig = Signature.generate(secret: @config.secret_token, timestamp: ts, body: signed_body)
|
|
166
|
+
uri = URI.join(@config.base_url, path)
|
|
167
|
+
uri.query = URI.encode_www_form(query) if query # delete-va reads query params, not the body
|
|
168
|
+
|
|
169
|
+
req = (method == :get ? Net::HTTP::Get : Net::HTTP::Post).new(uri)
|
|
170
|
+
req["Content-Type"] = "application/json"
|
|
171
|
+
req["x-client-id"] = @config.client_id
|
|
172
|
+
req["x-request-timestamp"] = ts
|
|
173
|
+
req["x-signature"] = sig
|
|
174
|
+
req.body = signed_body unless method == :get
|
|
175
|
+
|
|
176
|
+
parse(perform(uri, req))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Transport seam — tests override with canned responses (see client_test.rb).
|
|
180
|
+
def perform(uri, req)
|
|
181
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
182
|
+
http.use_ssl = uri.scheme == "https"
|
|
183
|
+
http.open_timeout = 10
|
|
184
|
+
http.read_timeout = @read_timeout
|
|
185
|
+
http.request(req)
|
|
186
|
+
rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
187
|
+
# Normalize transport failures to Tingee::Error so callers handle one error type —
|
|
188
|
+
# a bare timeout mid-link would otherwise crash the caller and strand the flow.
|
|
189
|
+
raise Error.new("NETWORK", e.message)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse(res)
|
|
193
|
+
body = res.body.to_s.empty? ? nil : JSON.parse(res.body)
|
|
194
|
+
return body if body.is_a?(Array) # get-banks: a bare array IS the success payload
|
|
195
|
+
|
|
196
|
+
raise Error.new("HTTP_#{res.code}", res.body.to_s) unless body.is_a?(Hash)
|
|
197
|
+
raise Error.new(body["code"], body["message"]) unless body["code"] == "00"
|
|
198
|
+
|
|
199
|
+
body["data"]
|
|
200
|
+
rescue JSON::ParserError
|
|
201
|
+
# A gateway/WAF error page (non-JSON) — surface as Tingee::Error, not a raw parse crash.
|
|
202
|
+
raise Error.new("HTTP_#{res.code}", res.body.to_s)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Tingee
|
|
2
|
+
# Injected credentials — set via Tingee.configure (e.g. from a Rails initializer);
|
|
3
|
+
# this layer never reads any credential store itself.
|
|
4
|
+
class Configuration
|
|
5
|
+
# shop_id: every VA this app creates is grouped under one Tingee shop
|
|
6
|
+
# (one shop per project). Optional: nil sends no shopId and Tingee
|
|
7
|
+
# auto-creates a throwaway shop per link.
|
|
8
|
+
attr_accessor :client_id, :secret_token, :base_url, :shop_id
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@base_url = "https://open-api.tingee.vn"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Called when a client is built, not at load — a credential-less env still boots.
|
|
15
|
+
def validate!
|
|
16
|
+
return if client_id && secret_token
|
|
17
|
+
|
|
18
|
+
raise Error.new("CONFIG", "Missing Tingee credentials (client_id, secret_token) — set them via Tingee.configure")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|